From 9ba364cc976791b7b5184a88f8343b03657a4ff5 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:36:02 +0200 Subject: [PATCH 1/7] fix(app): sync dock badge notifications --- .../src/main/core/app/app-badge-service.ts | 53 +++++++++++++++++++ .../src/main/core/app/controller.ts | 4 ++ apps/emdash-desktop/src/main/index.ts | 3 ++ apps/emdash-desktop/src/renderer/App.tsx | 2 + .../notification-badge-sync.tsx | 49 +++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 apps/emdash-desktop/src/main/core/app/app-badge-service.ts create mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx diff --git a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts new file mode 100644 index 0000000000..a424b96f30 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts @@ -0,0 +1,53 @@ +import { and, count, eq, inArray, isNull } from 'drizzle-orm'; +import { app } from 'electron'; +import { db } from '@main/db/client'; +import { conversations, tasks } from '@main/db/schema'; +import { log } from '@main/lib/logger'; + +class AppBadgeService { + private unreadCount = 0; + + async initialize(): Promise { + await this.sync(); + } + + async sync(): Promise { + try { + const [row] = await db + .select({ value: count() }) + .from(conversations) + .innerJoin(tasks, eq(tasks.id, conversations.taskId)) + .where( + and( + eq(conversations.agentStatusSeen, 0), + inArray(conversations.agentStatus, ['awaiting-input', 'error', 'completed']), + isNull(tasks.archivedAt) + ) + ); + + this.setCount(row?.value ?? 0, { force: true }); + } catch (error) { + log.warn('app-badge: failed to sync unread count', { error: String(error) }); + } + } + + clear(): void { + this.setCount(0, { force: true }); + } + + setVisibleNotificationCount(count: number): void { + this.setCount(Math.max(0, Math.floor(count)), { force: true }); + } + + private setCount(count: number, options: { force?: boolean } = {}): void { + if (!options.force && count === this.unreadCount) return; + + this.unreadCount = count; + const succeeded = app.setBadgeCount(count); + if (!succeeded && count > 0) { + log.debug('app-badge: platform did not accept badge count', { count }); + } + } +} + +export const appBadgeService = new AppBadgeService(); diff --git a/apps/emdash-desktop/src/main/core/app/controller.ts b/apps/emdash-desktop/src/main/core/app/controller.ts index ab3bb720a0..7f57ee0d81 100644 --- a/apps/emdash-desktop/src/main/core/app/controller.ts +++ b/apps/emdash-desktop/src/main/core/app/controller.ts @@ -2,6 +2,7 @@ import { getDiagnosticLogAttachment } from '@main/lib/file-logger'; import { telemetryService } from '@main/lib/telemetry'; import { createRPCController } from '@shared/lib/ipc/rpc'; import type { OpenInAppId } from '@shared/openInApps'; +import { appBadgeService } from './app-badge-service'; import { appService } from './service'; export const appController = createRPCController({ @@ -109,5 +110,8 @@ export const appController = createRPCController({ getAppVersion: () => appService.getCachedAppVersion(), getElectronVersion: () => process.versions.electron, getPlatform: () => process.platform, + setNotificationBadgeCount: (count: number) => { + appBadgeService.setVisibleNotificationCount(count); + }, getDiagnosticLogAttachment, }); diff --git a/apps/emdash-desktop/src/main/index.ts b/apps/emdash-desktop/src/main/index.ts index 2585678663..9831a25f00 100644 --- a/apps/emdash-desktop/src/main/index.ts +++ b/apps/emdash-desktop/src/main/index.ts @@ -12,6 +12,7 @@ import { createMainWindow } from './app/window'; import { providerTokenRegistry } from './core/account/provider-token-registry'; import { emdashAccountService } from './core/account/services/emdash-account-service'; import { agentHookService } from './core/agent-hooks/agent-hook-service'; +import { appBadgeService } from './core/app/app-badge-service'; import { appService } from './core/app/service'; import { automationsService } from './core/automations/automations-service'; import { cleanupLegacyBrowserPartitions } from './core/browser/browser-partition-cleanup'; @@ -133,6 +134,7 @@ void app.whenReady().then(async () => { prSyncScheduler.initialize(); automationsService.start(); appService.initialize(); + await appBadgeService.initialize(); await appSettingsService.initialize(); browserWebContentsRegistry.setKeyboardSettings(await appSettingsService.get('keyboard')); setBrowserCorsRelaxationSettings(await appSettingsService.get('browser')); @@ -193,6 +195,7 @@ app.on('before-quit', (event) => { telemetryService.capture('app_closed'); void telemetryService.dispose().finally(() => { automationsService.stop(); + appBadgeService.clear(); agentHookService.dispose(); stopResourceSampler(); updateService.dispose(); diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index 551a265f97..0e8fecaa30 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; +import { NotificationBadgeSync } from './features/command-palette/notification-badge-sync'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -99,6 +100,7 @@ function AppContent() { + diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx new file mode 100644 index 0000000000..1f465e4d32 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx @@ -0,0 +1,49 @@ +import { reaction } from 'mobx'; +import { useEffect } from 'react'; +import { + asMounted, + getProjectManagerStore, +} from '@renderer/features/projects/stores/project-selectors'; +import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; +import { isRegistered } from '@renderer/features/tasks/stores/task-store'; +import { rpc } from '@renderer/lib/ipc'; + +function getVisibleNotificationCount(): number { + let count = 0; + + for (const projectStore of getProjectManagerStore().projects.values()) { + const mounted = asMounted(projectStore); + if (!mounted) continue; + + for (const [taskId, taskStore] of mounted.taskManager.tasks) { + if (!isRegistered(taskStore)) continue; + if (taskStore.data.archivedAt) continue; + + const conversations = conversationRegistry.get(taskId); + if (!conversations) continue; + + const status = conversations.taskStatus; + if (status && status !== 'idle' && status !== 'working') { + count += 1; + } + } + } + + return count; +} + +export function NotificationBadgeSync() { + useEffect( + () => + reaction( + () => getVisibleNotificationCount(), + (count) => { + void rpc.app.setNotificationBadgeCount(count); + }, + { fireImmediately: true } + ), + [] + ); + + return null; +} From 94a52502a9324947496ab8819949d20fef71c03b Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:10:16 +0200 Subject: [PATCH 2/7] fix(app): refresh badge after task archive --- .../src/main/core/app/app-badge-service.ts | 4 -- .../src/main/core/app/controller.ts | 4 -- .../src/main/core/tasks/task-service.ts | 7 ++- apps/emdash-desktop/src/renderer/App.tsx | 2 - .../notification-badge-sync.tsx | 49 ------------------- 5 files changed, 6 insertions(+), 60 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx diff --git a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts index a424b96f30..cb9e42dbda 100644 --- a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts +++ b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts @@ -35,10 +35,6 @@ class AppBadgeService { this.setCount(0, { force: true }); } - setVisibleNotificationCount(count: number): void { - this.setCount(Math.max(0, Math.floor(count)), { force: true }); - } - private setCount(count: number, options: { force?: boolean } = {}): void { if (!options.force && count === this.unreadCount) return; diff --git a/apps/emdash-desktop/src/main/core/app/controller.ts b/apps/emdash-desktop/src/main/core/app/controller.ts index 7f57ee0d81..ab3bb720a0 100644 --- a/apps/emdash-desktop/src/main/core/app/controller.ts +++ b/apps/emdash-desktop/src/main/core/app/controller.ts @@ -2,7 +2,6 @@ import { getDiagnosticLogAttachment } from '@main/lib/file-logger'; import { telemetryService } from '@main/lib/telemetry'; import { createRPCController } from '@shared/lib/ipc/rpc'; import type { OpenInAppId } from '@shared/openInApps'; -import { appBadgeService } from './app-badge-service'; import { appService } from './service'; export const appController = createRPCController({ @@ -110,8 +109,5 @@ export const appController = createRPCController({ getAppVersion: () => appService.getCachedAppVersion(), getElectronVersion: () => process.versions.electron, getPlatform: () => process.platform, - setNotificationBadgeCount: (count: number) => { - appBadgeService.setVisibleNotificationCount(count); - }, getDiagnosticLogAttachment, }); diff --git a/apps/emdash-desktop/src/main/core/tasks/task-service.ts b/apps/emdash-desktop/src/main/core/tasks/task-service.ts index b4f1d1b786..85aee54e18 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-service.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-service.ts @@ -1,5 +1,6 @@ import { err, ok, type Result } from '@emdash/shared'; import { eq, sql } from 'drizzle-orm'; +import { appBadgeService } from '@main/core/app/app-badge-service'; import { projectManager } from '@main/core/projects/project-manager'; import { workspaceBootstrapService, @@ -181,12 +182,16 @@ export class TaskService implements Hookable { async archiveTask(projectId: string, taskId: string): Promise { await archiveTask(projectId, taskId); + void appBadgeService.sync(); this._hooks.callHookBackground('task:archived', taskId, projectId); } async restoreTask(id: string): Promise { const task = await restoreTask(id); - if (task) this._hooks.callHookBackground('task:updated', task); + if (task) { + void appBadgeService.sync(); + this._hooks.callHookBackground('task:updated', task); + } } async renameTask( diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index 0e8fecaa30..551a265f97 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; -import { NotificationBadgeSync } from './features/command-palette/notification-badge-sync'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -100,7 +99,6 @@ function AppContent() { - diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx deleted file mode 100644 index 1f465e4d32..0000000000 --- a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { reaction } from 'mobx'; -import { useEffect } from 'react'; -import { - asMounted, - getProjectManagerStore, -} from '@renderer/features/projects/stores/project-selectors'; -import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; -import { isRegistered } from '@renderer/features/tasks/stores/task-store'; -import { rpc } from '@renderer/lib/ipc'; - -function getVisibleNotificationCount(): number { - let count = 0; - - for (const projectStore of getProjectManagerStore().projects.values()) { - const mounted = asMounted(projectStore); - if (!mounted) continue; - - for (const [taskId, taskStore] of mounted.taskManager.tasks) { - if (!isRegistered(taskStore)) continue; - if (taskStore.data.archivedAt) continue; - - const conversations = conversationRegistry.get(taskId); - if (!conversations) continue; - - const status = conversations.taskStatus; - if (status && status !== 'idle' && status !== 'working') { - count += 1; - } - } - } - - return count; -} - -export function NotificationBadgeSync() { - useEffect( - () => - reaction( - () => getVisibleNotificationCount(), - (count) => { - void rpc.app.setNotificationBadgeCount(count); - }, - { fireImmediately: true } - ), - [] - ); - - return null; -} From bdfac9a9ed645abb68f94979f2e77e7c978629aa Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:41:13 +0200 Subject: [PATCH 3/7] fix(app): match badge to command palette --- .../src/main/core/app/app-badge-service.ts | 31 +++--------- .../src/main/core/app/controller.ts | 4 ++ .../src/main/core/tasks/task-service.ts | 3 -- apps/emdash-desktop/src/main/index.ts | 2 +- apps/emdash-desktop/src/renderer/App.tsx | 2 + .../notification-badge-sync.tsx | 50 +++++++++++++++++++ .../palette-notifications-group.tsx | 45 +---------------- .../command-palette/palette-notifications.ts | 45 +++++++++++++++++ 8 files changed, 110 insertions(+), 72 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts diff --git a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts index cb9e42dbda..6a585b3e5b 100644 --- a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts +++ b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts @@ -1,40 +1,21 @@ -import { and, count, eq, inArray, isNull } from 'drizzle-orm'; import { app } from 'electron'; -import { db } from '@main/db/client'; -import { conversations, tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; class AppBadgeService { private unreadCount = 0; - async initialize(): Promise { - await this.sync(); - } - - async sync(): Promise { - try { - const [row] = await db - .select({ value: count() }) - .from(conversations) - .innerJoin(tasks, eq(tasks.id, conversations.taskId)) - .where( - and( - eq(conversations.agentStatusSeen, 0), - inArray(conversations.agentStatus, ['awaiting-input', 'error', 'completed']), - isNull(tasks.archivedAt) - ) - ); - - this.setCount(row?.value ?? 0, { force: true }); - } catch (error) { - log.warn('app-badge: failed to sync unread count', { error: String(error) }); - } + initialize(): void { + this.clear(); } clear(): void { this.setCount(0, { force: true }); } + setVisibleNotificationCount(count: number): void { + this.setCount(Math.max(0, Math.floor(count)), { force: true }); + } + private setCount(count: number, options: { force?: boolean } = {}): void { if (!options.force && count === this.unreadCount) return; diff --git a/apps/emdash-desktop/src/main/core/app/controller.ts b/apps/emdash-desktop/src/main/core/app/controller.ts index ab3bb720a0..7f57ee0d81 100644 --- a/apps/emdash-desktop/src/main/core/app/controller.ts +++ b/apps/emdash-desktop/src/main/core/app/controller.ts @@ -2,6 +2,7 @@ import { getDiagnosticLogAttachment } from '@main/lib/file-logger'; import { telemetryService } from '@main/lib/telemetry'; import { createRPCController } from '@shared/lib/ipc/rpc'; import type { OpenInAppId } from '@shared/openInApps'; +import { appBadgeService } from './app-badge-service'; import { appService } from './service'; export const appController = createRPCController({ @@ -109,5 +110,8 @@ export const appController = createRPCController({ getAppVersion: () => appService.getCachedAppVersion(), getElectronVersion: () => process.versions.electron, getPlatform: () => process.platform, + setNotificationBadgeCount: (count: number) => { + appBadgeService.setVisibleNotificationCount(count); + }, getDiagnosticLogAttachment, }); diff --git a/apps/emdash-desktop/src/main/core/tasks/task-service.ts b/apps/emdash-desktop/src/main/core/tasks/task-service.ts index 85aee54e18..2fac908d2e 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-service.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-service.ts @@ -1,6 +1,5 @@ import { err, ok, type Result } from '@emdash/shared'; import { eq, sql } from 'drizzle-orm'; -import { appBadgeService } from '@main/core/app/app-badge-service'; import { projectManager } from '@main/core/projects/project-manager'; import { workspaceBootstrapService, @@ -182,14 +181,12 @@ export class TaskService implements Hookable { async archiveTask(projectId: string, taskId: string): Promise { await archiveTask(projectId, taskId); - void appBadgeService.sync(); this._hooks.callHookBackground('task:archived', taskId, projectId); } async restoreTask(id: string): Promise { const task = await restoreTask(id); if (task) { - void appBadgeService.sync(); this._hooks.callHookBackground('task:updated', task); } } diff --git a/apps/emdash-desktop/src/main/index.ts b/apps/emdash-desktop/src/main/index.ts index 9831a25f00..419054eb94 100644 --- a/apps/emdash-desktop/src/main/index.ts +++ b/apps/emdash-desktop/src/main/index.ts @@ -134,7 +134,7 @@ void app.whenReady().then(async () => { prSyncScheduler.initialize(); automationsService.start(); appService.initialize(); - await appBadgeService.initialize(); + appBadgeService.initialize(); await appSettingsService.initialize(); browserWebContentsRegistry.setKeyboardSettings(await appSettingsService.get('keyboard')); setBrowserCorsRelaxationSettings(await appSettingsService.get('browser')); diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index 551a265f97..0e8fecaa30 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; +import { NotificationBadgeSync } from './features/command-palette/notification-badge-sync'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -99,6 +100,7 @@ function AppContent() { + diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx new file mode 100644 index 0000000000..844a7d3f96 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx @@ -0,0 +1,50 @@ +import { reaction } from 'mobx'; +import { useEffect } from 'react'; +import { rpc } from '@renderer/lib/ipc'; +import { appState } from '@renderer/lib/stores/app-state'; +import { getPaletteNotificationItems } from './palette-notifications'; + +function getCurrentNotificationContext(): { + currentProjectId: string | undefined; + currentTaskId: string | undefined; +} { + const { currentViewId, viewParamsStore } = appState.navigation; + + if (currentViewId === 'task') { + const params = viewParamsStore.task; + return { + currentProjectId: params?.projectId, + currentTaskId: params?.taskId, + }; + } + + if (currentViewId === 'project') { + return { + currentProjectId: viewParamsStore.project?.projectId, + currentTaskId: undefined, + }; + } + + return { currentProjectId: undefined, currentTaskId: undefined }; +} + +function getCurrentPaletteNotificationCount(): number { + const { currentProjectId, currentTaskId } = getCurrentNotificationContext(); + return getPaletteNotificationItems(currentProjectId, currentTaskId).length; +} + +export function NotificationBadgeSync() { + useEffect( + () => + reaction( + () => getCurrentPaletteNotificationCount(), + (count) => { + void rpc.app.setNotificationBadgeCount(count); + }, + { fireImmediately: true } + ), + [] + ); + + return null; +} diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx index bdd37bb893..6dfbe26b68 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx +++ b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx @@ -1,22 +1,12 @@ import { Command } from 'cmdk'; import { useObserver } from 'mobx-react-lite'; -import { - asMounted, - getProjectManagerStore, -} from '@renderer/features/projects/stores/project-selectors'; -import type { ConversationStore } from '@renderer/features/tasks/conversations/conversation-manager'; -import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; -import { isRegistered, type TaskStore } from '@renderer/features/tasks/stores/task-store'; import type { NavigateFnTyped } from '@renderer/lib/layout/navigation-provider'; import { cn } from '@renderer/utils/utils'; import { PaletteConversationItem } from './palette-conversation-item'; +import { getPaletteNotificationItems } from './palette-notifications'; import { PaletteTaskItem } from './palette-task-item'; -type NotificationItem = - | { kind: 'task'; projectId: string; taskStore: TaskStore } - | { kind: 'conversation'; projectId: string; taskId: string; conv: ConversationStore }; - const GROUP_CLASS = cn( '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5', '[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium', @@ -36,38 +26,7 @@ export function PaletteNotificationsGroup({ onClose, navigate, }: PaletteNotificationsGroupProps) { - const items = useObserver((): NotificationItem[] => { - const result: NotificationItem[] = []; - - for (const projectStore of getProjectManagerStore().projects.values()) { - const mounted = asMounted(projectStore); - if (!mounted) continue; - const pid = mounted.data.id; - - for (const [tid, taskStore] of mounted.taskManager.tasks) { - if (!isRegistered(taskStore)) continue; - const conversations = conversationRegistry.get(tid); - if (!conversations) continue; - - const status = conversations.taskStatus; - // Only surface awaiting-input, error, completed — not working or idle. - if (!status || status === 'idle' || status === 'working') continue; - - if (pid === currentProjectId && tid === currentTaskId) { - // We're already in this task — surface individual unseen conversations. - for (const conv of conversations.conversations.values()) { - if (!conv.seen && conv.indicatorStatus) { - result.push({ kind: 'conversation', projectId: pid, taskId: tid, conv }); - } - } - } else { - result.push({ kind: 'task', projectId: pid, taskStore }); - } - } - } - - return result; - }); + const items = useObserver(() => getPaletteNotificationItems(currentProjectId, currentTaskId)); if (items.length === 0) return null; diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts new file mode 100644 index 0000000000..dc22f2f8f7 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts @@ -0,0 +1,45 @@ +import { + asMounted, + getProjectManagerStore, +} from '@renderer/features/projects/stores/project-selectors'; +import type { ConversationStore } from '@renderer/features/tasks/conversations/conversation-manager'; +import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; +import { isRegistered, type TaskStore } from '@renderer/features/tasks/stores/task-store'; + +export type NotificationItem = + | { kind: 'task'; projectId: string; taskStore: TaskStore } + | { kind: 'conversation'; projectId: string; taskId: string; conv: ConversationStore }; + +export function getPaletteNotificationItems( + currentProjectId: string | undefined, + currentTaskId: string | undefined +): NotificationItem[] { + const result: NotificationItem[] = []; + + for (const projectStore of getProjectManagerStore().projects.values()) { + const mounted = asMounted(projectStore); + if (!mounted) continue; + const pid = mounted.data.id; + + for (const [tid, taskStore] of mounted.taskManager.tasks) { + if (!isRegistered(taskStore)) continue; + const conversations = conversationRegistry.get(tid); + if (!conversations) continue; + + const status = conversations.taskStatus; + if (!status || status === 'idle' || status === 'working') continue; + + if (pid === currentProjectId && tid === currentTaskId) { + for (const conv of conversations.conversations.values()) { + if (!conv.seen && conv.indicatorStatus) { + result.push({ kind: 'conversation', projectId: pid, taskId: tid, conv }); + } + } + } else { + result.push({ kind: 'task', projectId: pid, taskStore }); + } + } + } + + return result; +} From 74844885cc765b45991b593b9154af961827dc00 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:46:28 +0200 Subject: [PATCH 4/7] refactor(renderer): centralize task notification helpers Move palette notification lookup and badge count logic into the task notifications store so both the command palette and app badge sync use the same source of truth. --- apps/emdash-desktop/src/renderer/App.tsx | 2 +- .../renderer/app/notification-badge-sync.tsx | 20 +++++ .../notification-badge-sync.tsx | 50 ------------- .../palette-notifications-group.tsx | 4 +- .../command-palette/palette-notifications.ts | 45 ------------ .../tasks/stores/task-notifications.ts | 73 +++++++++++++++++++ 6 files changed, 96 insertions(+), 98 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index 0e8fecaa30..06d39bd54e 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -1,9 +1,9 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; +import { NotificationBadgeSync } from './app/notification-badge-sync'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; -import { NotificationBadgeSync } from './features/command-palette/notification-badge-sync'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; diff --git a/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx new file mode 100644 index 0000000000..ab8569c001 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx @@ -0,0 +1,20 @@ +import { reaction } from 'mobx'; +import { useEffect } from 'react'; +import { getVisibleTaskNotificationCount } from '@renderer/features/tasks/stores/task-notifications'; +import { rpc } from '@renderer/lib/ipc'; + +export function NotificationBadgeSync() { + useEffect( + () => + reaction( + () => getVisibleTaskNotificationCount(), + (count) => { + void rpc.app.setNotificationBadgeCount(count); + }, + { fireImmediately: true } + ), + [] + ); + + return null; +} diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx deleted file mode 100644 index 844a7d3f96..0000000000 --- a/apps/emdash-desktop/src/renderer/features/command-palette/notification-badge-sync.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { reaction } from 'mobx'; -import { useEffect } from 'react'; -import { rpc } from '@renderer/lib/ipc'; -import { appState } from '@renderer/lib/stores/app-state'; -import { getPaletteNotificationItems } from './palette-notifications'; - -function getCurrentNotificationContext(): { - currentProjectId: string | undefined; - currentTaskId: string | undefined; -} { - const { currentViewId, viewParamsStore } = appState.navigation; - - if (currentViewId === 'task') { - const params = viewParamsStore.task; - return { - currentProjectId: params?.projectId, - currentTaskId: params?.taskId, - }; - } - - if (currentViewId === 'project') { - return { - currentProjectId: viewParamsStore.project?.projectId, - currentTaskId: undefined, - }; - } - - return { currentProjectId: undefined, currentTaskId: undefined }; -} - -function getCurrentPaletteNotificationCount(): number { - const { currentProjectId, currentTaskId } = getCurrentNotificationContext(); - return getPaletteNotificationItems(currentProjectId, currentTaskId).length; -} - -export function NotificationBadgeSync() { - useEffect( - () => - reaction( - () => getCurrentPaletteNotificationCount(), - (count) => { - void rpc.app.setNotificationBadgeCount(count); - }, - { fireImmediately: true } - ), - [] - ); - - return null; -} diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx index 6dfbe26b68..b412d27cc8 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx +++ b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx @@ -1,10 +1,10 @@ import { Command } from 'cmdk'; import { useObserver } from 'mobx-react-lite'; import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; +import { getTaskNotificationItems } from '@renderer/features/tasks/stores/task-notifications'; import type { NavigateFnTyped } from '@renderer/lib/layout/navigation-provider'; import { cn } from '@renderer/utils/utils'; import { PaletteConversationItem } from './palette-conversation-item'; -import { getPaletteNotificationItems } from './palette-notifications'; import { PaletteTaskItem } from './palette-task-item'; const GROUP_CLASS = cn( @@ -26,7 +26,7 @@ export function PaletteNotificationsGroup({ onClose, navigate, }: PaletteNotificationsGroupProps) { - const items = useObserver(() => getPaletteNotificationItems(currentProjectId, currentTaskId)); + const items = useObserver(() => getTaskNotificationItems(currentProjectId, currentTaskId)); if (items.length === 0) return null; diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts deleted file mode 100644 index dc22f2f8f7..0000000000 --- a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - asMounted, - getProjectManagerStore, -} from '@renderer/features/projects/stores/project-selectors'; -import type { ConversationStore } from '@renderer/features/tasks/conversations/conversation-manager'; -import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; -import { isRegistered, type TaskStore } from '@renderer/features/tasks/stores/task-store'; - -export type NotificationItem = - | { kind: 'task'; projectId: string; taskStore: TaskStore } - | { kind: 'conversation'; projectId: string; taskId: string; conv: ConversationStore }; - -export function getPaletteNotificationItems( - currentProjectId: string | undefined, - currentTaskId: string | undefined -): NotificationItem[] { - const result: NotificationItem[] = []; - - for (const projectStore of getProjectManagerStore().projects.values()) { - const mounted = asMounted(projectStore); - if (!mounted) continue; - const pid = mounted.data.id; - - for (const [tid, taskStore] of mounted.taskManager.tasks) { - if (!isRegistered(taskStore)) continue; - const conversations = conversationRegistry.get(tid); - if (!conversations) continue; - - const status = conversations.taskStatus; - if (!status || status === 'idle' || status === 'working') continue; - - if (pid === currentProjectId && tid === currentTaskId) { - for (const conv of conversations.conversations.values()) { - if (!conv.seen && conv.indicatorStatus) { - result.push({ kind: 'conversation', projectId: pid, taskId: tid, conv }); - } - } - } else { - result.push({ kind: 'task', projectId: pid, taskStore }); - } - } - } - - return result; -} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts new file mode 100644 index 0000000000..3f98f47cef --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts @@ -0,0 +1,73 @@ +import { + asMounted, + getProjectManagerStore, +} from '@renderer/features/projects/stores/project-selectors'; +import type { ConversationStore } from '@renderer/features/tasks/conversations/conversation-manager'; +import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; +import { isRegistered, type TaskStore } from '@renderer/features/tasks/stores/task-store'; + +export type TaskNotificationItem = + | { kind: 'task'; projectId: string; taskStore: TaskStore } + | { kind: 'conversation'; projectId: string; taskId: string; conv: ConversationStore }; + +function hasVisibleTaskNotification(taskId: string): boolean { + const conversations = conversationRegistry.get(taskId); + if (!conversations) return false; + + const status = conversations.taskStatus; + return status !== null && status !== 'idle' && status !== 'working'; +} + +export function getVisibleTaskNotificationCount(): number { + let count = 0; + + for (const projectStore of getProjectManagerStore().projects.values()) { + const mounted = asMounted(projectStore); + if (!mounted) continue; + + for (const [taskId, taskStore] of mounted.taskManager.tasks) { + if (!isRegistered(taskStore)) continue; + if (hasVisibleTaskNotification(taskId)) count += 1; + } + } + + return count; +} + +export function getTaskNotificationItems( + currentProjectId: string | undefined, + currentTaskId: string | undefined +): TaskNotificationItem[] { + const result: TaskNotificationItem[] = []; + + for (const projectStore of getProjectManagerStore().projects.values()) { + const mounted = asMounted(projectStore); + if (!mounted) continue; + const projectId = mounted.data.id; + + for (const [taskId, taskStore] of mounted.taskManager.tasks) { + if (!isRegistered(taskStore)) continue; + if (!hasVisibleTaskNotification(taskId)) continue; + + if (projectId === currentProjectId && taskId === currentTaskId) { + const conversations = conversationRegistry.get(taskId); + if (!conversations) continue; + + for (const conversation of conversations.conversations.values()) { + if (!conversation.seen && conversation.indicatorStatus) { + result.push({ + kind: 'conversation', + projectId, + taskId, + conv: conversation, + }); + } + } + } else { + result.push({ kind: 'task', projectId, taskStore }); + } + } + } + + return result; +} From 5da51f25a709700f4cb90a31c3c805e667f2db62 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:47:54 +0200 Subject: [PATCH 5/7] style(renderer): sort notification imports --- .../features/command-palette/palette-notifications-group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx index b412d27cc8..ae2f45699b 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx +++ b/apps/emdash-desktop/src/renderer/features/command-palette/palette-notifications-group.tsx @@ -1,7 +1,7 @@ import { Command } from 'cmdk'; import { useObserver } from 'mobx-react-lite'; -import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; import { getTaskNotificationItems } from '@renderer/features/tasks/stores/task-notifications'; +import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; import type { NavigateFnTyped } from '@renderer/lib/layout/navigation-provider'; import { cn } from '@renderer/utils/utils'; import { PaletteConversationItem } from './palette-conversation-item'; From 22d24dc3d32807ca1f630345f2ef1a8e839e4610 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:12:53 +0200 Subject: [PATCH 6/7] fix(app): ignore archived task notifications --- .../src/renderer/features/tasks/stores/task-notifications.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts index 3f98f47cef..f175f19dbc 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts @@ -27,6 +27,7 @@ export function getVisibleTaskNotificationCount(): number { for (const [taskId, taskStore] of mounted.taskManager.tasks) { if (!isRegistered(taskStore)) continue; + if (taskStore.data.archivedAt) continue; if (hasVisibleTaskNotification(taskId)) count += 1; } } @@ -47,6 +48,7 @@ export function getTaskNotificationItems( for (const [taskId, taskStore] of mounted.taskManager.tasks) { if (!isRegistered(taskStore)) continue; + if (taskStore.data.archivedAt) continue; if (!hasVisibleTaskNotification(taskId)) continue; if (projectId === currentProjectId && taskId === currentTaskId) { From 9c03e8d7b89f0e44d5d84a104ee103706d8ffc94 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:59:07 +0200 Subject: [PATCH 7/7] fix(app): align notification badge count --- .../src/main/core/app/app-badge-service.ts | 8 ++--- .../renderer/app/notification-badge-sync.tsx | 10 +++++- .../tasks/stores/task-notifications.ts | 31 +++++++++++++++---- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts index 6a585b3e5b..af7571c138 100644 --- a/apps/emdash-desktop/src/main/core/app/app-badge-service.ts +++ b/apps/emdash-desktop/src/main/core/app/app-badge-service.ts @@ -9,15 +9,15 @@ class AppBadgeService { } clear(): void { - this.setCount(0, { force: true }); + this.setCount(0); } setVisibleNotificationCount(count: number): void { - this.setCount(Math.max(0, Math.floor(count)), { force: true }); + this.setCount(Math.max(0, Math.floor(count))); } - private setCount(count: number, options: { force?: boolean } = {}): void { - if (!options.force && count === this.unreadCount) return; + private setCount(count: number): void { + if (count === this.unreadCount) return; this.unreadCount = count; const succeeded = app.setBadgeCount(count); diff --git a/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx b/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx index ab8569c001..f916426738 100644 --- a/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx +++ b/apps/emdash-desktop/src/renderer/app/notification-badge-sync.tsx @@ -2,12 +2,20 @@ import { reaction } from 'mobx'; import { useEffect } from 'react'; import { getVisibleTaskNotificationCount } from '@renderer/features/tasks/stores/task-notifications'; import { rpc } from '@renderer/lib/ipc'; +import { appState } from '@renderer/lib/stores/app-state'; export function NotificationBadgeSync() { useEffect( () => reaction( - () => getVisibleTaskNotificationCount(), + () => { + const taskParams = + appState.navigation.currentViewId === 'task' + ? appState.navigation.viewParamsStore.task + : undefined; + + return getVisibleTaskNotificationCount(taskParams?.projectId, taskParams?.taskId); + }, (count) => { void rpc.app.setNotificationBadgeCount(count); }, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts index f175f19dbc..6480da47f3 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-notifications.ts @@ -18,17 +18,38 @@ function hasVisibleTaskNotification(taskId: string): boolean { return status !== null && status !== 'idle' && status !== 'working'; } -export function getVisibleTaskNotificationCount(): number { +function getUnseenConversationNotificationCount(taskId: string): number { + const conversations = conversationRegistry.get(taskId); + if (!conversations) return 0; + + let count = 0; + for (const conversation of conversations.conversations.values()) { + if (!conversation.seen && conversation.indicatorStatus) count += 1; + } + return count; +} + +export function getVisibleTaskNotificationCount( + currentProjectId: string | undefined, + currentTaskId: string | undefined +): number { let count = 0; for (const projectStore of getProjectManagerStore().projects.values()) { const mounted = asMounted(projectStore); if (!mounted) continue; + const projectId = mounted.data.id; for (const [taskId, taskStore] of mounted.taskManager.tasks) { if (!isRegistered(taskStore)) continue; if (taskStore.data.archivedAt) continue; - if (hasVisibleTaskNotification(taskId)) count += 1; + if (!hasVisibleTaskNotification(taskId)) continue; + + if (projectId === currentProjectId && taskId === currentTaskId) { + count += getUnseenConversationNotificationCount(taskId); + } else { + count += 1; + } } } @@ -52,10 +73,8 @@ export function getTaskNotificationItems( if (!hasVisibleTaskNotification(taskId)) continue; if (projectId === currentProjectId && taskId === currentTaskId) { - const conversations = conversationRegistry.get(taskId); - if (!conversations) continue; - - for (const conversation of conversations.conversations.values()) { + const conversations = conversationRegistry.get(taskId)?.conversations.values() ?? []; + for (const conversation of conversations) { if (!conversation.seen && conversation.indicatorStatus) { result.push({ kind: 'conversation',