diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index 8b0f87e1086..e658226e639 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,35 +1,50 @@ -import { observable } from "@trpc/server/observable"; import { type AgentCompleteEvent, type NotificationIds, notificationsEmitter, } from "main/lib/notifications/server"; +import { NOTIFICATION_EVENTS } from "shared/constants"; import { publicProcedure, router } from ".."; type NotificationEvent = - | { type: "agent-complete"; data: AgentCompleteEvent } - | { type: "focus-tab"; data: NotificationIds }; + | { + type: typeof NOTIFICATION_EVENTS.AGENT_COMPLETE; + data?: AgentCompleteEvent; + } + | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds }; export const createNotificationsRouter = () => { return router({ - subscribe: publicProcedure.subscription(() => { - return observable((emit) => { - const onComplete = (data: AgentCompleteEvent) => { - emit.next({ type: "agent-complete", data }); - }; + subscribe: publicProcedure.subscription(async function* () { + const queue: NotificationEvent[] = []; - const onFocusTab = (data: NotificationIds) => { - emit.next({ type: "focus-tab", data }); - }; + const onComplete = (data: AgentCompleteEvent) => { + queue.push({ type: NOTIFICATION_EVENTS.AGENT_COMPLETE, data }); + }; - notificationsEmitter.on("agent-complete", onComplete); - notificationsEmitter.on("focus-tab", onFocusTab); + const onFocusTab = (data: NotificationIds) => { + queue.push({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); + }; - return () => { - notificationsEmitter.off("agent-complete", onComplete); - notificationsEmitter.off("focus-tab", onFocusTab); - }; - }); + notificationsEmitter.on(NOTIFICATION_EVENTS.AGENT_COMPLETE, onComplete); + notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + + try { + while (true) { + const event = queue.shift(); + if (event) { + yield event; + } else { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } finally { + notificationsEmitter.off( + NOTIFICATION_EVENTS.AGENT_COMPLETE, + onComplete, + ); + notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + } }), }); }; diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index c40eb9e7048..8c5d27d07e5 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import express from "express"; +import { NOTIFICATION_EVENTS } from "shared/constants"; export interface NotificationIds { paneId?: string; @@ -36,7 +37,7 @@ app.get("/hook/complete", (req, res) => { eventType: eventType === "PermissionRequest" ? "PermissionRequest" : "Stop", }; - notificationsEmitter.emit("agent-complete", event); + notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_COMPLETE, event); res.json({ success: true, paneId, tabId }); }); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 6c563fe6857..7fded0d927b 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -3,7 +3,7 @@ import type { BrowserWindow } from "electron"; import { Notification, screen } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; -import { PORTS } from "shared/constants"; +import { NOTIFICATION_EVENTS, PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; @@ -78,72 +78,78 @@ export async function MainWindow() { ); // Handle agent completion notifications - notificationsEmitter.on("agent-complete", (event: AgentCompleteEvent) => { - if (Notification.isSupported()) { - const isPermissionRequest = event.eventType === "PermissionRequest"; - - // Derive workspace name from workspaceId with safe fallbacks - let workspaceName = "Workspace"; - try { - const workspaces = db.data?.workspaces; - const worktrees = db.data?.worktrees; - if (Array.isArray(workspaces) && Array.isArray(worktrees)) { - const workspace = workspaces.find((w) => w.id === event.workspaceId); - const worktree = workspace - ? worktrees.find((wt) => wt.id === workspace.worktreeId) + notificationsEmitter.on( + NOTIFICATION_EVENTS.AGENT_COMPLETE, + (event: AgentCompleteEvent) => { + if (Notification.isSupported()) { + const isPermissionRequest = event.eventType === "PermissionRequest"; + + // Derive workspace name from workspaceId with safe fallbacks + let workspaceName = "Workspace"; + try { + const workspaces = db.data?.workspaces; + const worktrees = db.data?.worktrees; + if (Array.isArray(workspaces) && Array.isArray(worktrees)) { + const workspace = workspaces.find( + (w) => w.id === event.workspaceId, + ); + const worktree = workspace + ? worktrees.find((wt) => wt.id === workspace.worktreeId) + : undefined; + workspaceName = workspace?.name || worktree?.branch || "Workspace"; + } + } catch (error) { + console.error( + "[notifications] Failed to access db for workspace name:", + error, + ); + } + + // Derive title from tab name, falling back to pane name + // Priority: tab.userTitle (user-set name) > tab.name (auto-generated) > pane.name > "Terminal" + let title = "Terminal"; + try { + const { paneId, tabId } = event; + const tabsState = appState.data?.tabsState; + const pane = paneId ? tabsState?.panes?.[paneId] : undefined; + const tab = tabId + ? tabsState?.tabs?.find((t) => t.id === tabId) : undefined; - workspaceName = workspace?.name || worktree?.branch || "Workspace"; + title = + tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal"; + } catch (error) { + console.error( + "[notifications] Failed to access appState for tab title:", + error, + ); } - } catch (error) { - console.error( - "[notifications] Failed to access db for workspace name:", - error, - ); - } - // Derive title from tab name, falling back to pane name - // Priority: tab.userTitle (user-set name) > tab.name (auto-generated) > pane.name > "Terminal" - let title = "Terminal"; - try { - const { paneId, tabId } = event; - const tabsState = appState.data?.tabsState; - const pane = paneId ? tabsState?.panes?.[paneId] : undefined; - const tab = tabId - ? tabsState?.tabs?.find((t) => t.id === tabId) - : undefined; - title = tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal"; - } catch (error) { - console.error( - "[notifications] Failed to access appState for tab title:", - error, - ); - } + const notification = new Notification({ + title: isPermissionRequest + ? `Input Needed — ${workspaceName}` + : `Agent Complete — ${workspaceName}`, + body: isPermissionRequest + ? `"${title}" needs your attention` + : `"${title}" has finished its task`, + silent: true, + }); - const notification = new Notification({ - title: isPermissionRequest - ? `Input Needed — ${workspaceName}` - : `Agent Complete — ${workspaceName}`, - body: isPermissionRequest - ? `"${title}" needs your attention` - : `"${title}" has finished its task`, - silent: true, - }); - - playNotificationSound(); - - notification.on("click", () => { - window.show(); - window.focus(); - notificationsEmitter.emit("focus-tab", { - paneId: event.paneId, - tabId: event.tabId, - workspaceId: event.workspaceId, + playNotificationSound(); + + notification.on("click", () => { + window.show(); + window.focus(); + notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, { + paneId: event.paneId, + tabId: event.tabId, + workspaceId: event.workspaceId, + }); }); - }); - notification.show(); - } - }); + notification.show(); + } + }, + ); window.webContents.on("did-finish-load", async () => { window.show(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx index 3ac3bf5e6c2..9b521fb6d2d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx @@ -122,66 +122,68 @@ export function TabItem({ tab, index, isActive }: TabItemProps) { onClose={handleRemoveTab} onRename={startRename} > - - + ); diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 2bb887208a9..3863f189815 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,5 +1,6 @@ import { trpc } from "renderer/lib/trpc"; import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace"; +import { NOTIFICATION_EVENTS } from "shared/constants"; import { useAppStore } from "../app-state"; import { useTabsStore } from "./store"; import { resolveNotificationTarget } from "./utils/resolve-notification-target"; @@ -14,13 +15,15 @@ export function useAgentHookListener() { trpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { + if (!event.data) return; + const state = useTabsStore.getState(); const target = resolveNotificationTarget(event.data, state); if (!target) return; const { paneId, workspaceId } = target; - if (event.type === "agent-complete") { + if (event.type === NOTIFICATION_EVENTS.AGENT_COMPLETE) { if (!paneId) return; // Only show red dot if not already viewing this pane @@ -32,7 +35,7 @@ export function useAgentHookListener() { if (!isAlreadyActive) { state.setNeedsAttention(paneId, true); } - } else if (event.type === "focus-tab") { + } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { // Switch to workspace view if not already there const appState = useAppStore.getState(); if (appState.currentView !== "workspace") { diff --git a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts index b3224cb0b88..65bcf1ea01a 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts @@ -15,9 +15,11 @@ interface ResolvedTarget extends NotificationIds { * Priority: event data > pane's tab > tab's workspace */ export function resolveNotificationTarget( - ids: NotificationIds, + ids: NotificationIds | undefined, state: TabsState, ): ResolvedTarget | null { + if (!ids) return null; + const { paneId, tabId, workspaceId } = ids; const pane = paneId ? state.panes[paneId] : undefined; diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 305fedccfbd..66f2cc98fd4 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -42,3 +42,9 @@ export const CONFIG_TEMPLATE = `{ "setup": [], "teardown": [] }`; + +// Notification event types +export const NOTIFICATION_EVENTS = { + AGENT_COMPLETE: "agent-complete", + FOCUS_TAB: "focus-tab", +} as const;