diff --git a/apps/desktop/src/main/lib/notifications/utils.test.ts b/apps/desktop/src/main/lib/notifications/utils.test.ts new file mode 100644 index 00000000000..e6b3196dde4 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/utils.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "bun:test"; +import { + extractWorkspaceIdFromUrl, + getNotificationTitle, + getWorkspaceName, + isPaneVisible, +} from "./utils"; + +describe("extractWorkspaceIdFromUrl", () => { + it("extracts workspace ID from hash-routed URL", () => { + const url = "file:///app/index.html#/workspace/abc123"; + expect(extractWorkspaceIdFromUrl(url)).toBe("abc123"); + }); + + it("extracts workspace ID when file path contains /workspace/", () => { + // This is the key case - file path has /workspace/ but we should extract from hash + const url = + "file:///Users/foo/workspace/superset/dist/index.html#/workspace/def456"; + expect(extractWorkspaceIdFromUrl(url)).toBe("def456"); + }); + + it("handles query params in hash", () => { + const url = "file:///app/index.html#/workspace/ghi789?foo=bar"; + expect(extractWorkspaceIdFromUrl(url)).toBe("ghi789"); + }); + + it("handles nested hash fragments", () => { + const url = "file:///app/index.html#/workspace/jkl012#section"; + expect(extractWorkspaceIdFromUrl(url)).toBe("jkl012"); + }); + + it("handles UUIDs as workspace IDs", () => { + const url = + "file:///app/index.html#/workspace/550e8400-e29b-41d4-a716-446655440000"; + expect(extractWorkspaceIdFromUrl(url)).toBe( + "550e8400-e29b-41d4-a716-446655440000", + ); + }); + + it("returns null when no workspace in hash", () => { + const url = "file:///app/index.html#/settings/account"; + expect(extractWorkspaceIdFromUrl(url)).toBeNull(); + }); + + it("returns null when URL has no hash", () => { + const url = "file:///app/index.html"; + expect(extractWorkspaceIdFromUrl(url)).toBeNull(); + }); + + it("returns null for invalid URL", () => { + expect(extractWorkspaceIdFromUrl("not-a-valid-url")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractWorkspaceIdFromUrl("")).toBeNull(); + }); + + it("handles http URLs with hash routing", () => { + const url = "http://localhost:5173/#/workspace/mno345"; + expect(extractWorkspaceIdFromUrl(url)).toBe("mno345"); + }); +}); + +describe("isPaneVisible", () => { + const pane = { workspaceId: "ws1", tabId: "tab1", paneId: "pane1" }; + + it("returns true when pane is fully visible", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: { + activeTabIds: { ws1: "tab1" }, + focusedPaneIds: { tab1: "pane1" }, + }, + pane, + }), + ).toBe(true); + }); + + it("returns false when viewing different workspace", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws2", + tabsState: { + activeTabIds: { ws1: "tab1" }, + focusedPaneIds: { tab1: "pane1" }, + }, + pane, + }), + ).toBe(false); + }); + + it("returns false when different tab is active", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: { + activeTabIds: { ws1: "tab2" }, + focusedPaneIds: { tab1: "pane1" }, + }, + pane, + }), + ).toBe(false); + }); + + it("returns false when different pane is focused", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: { + activeTabIds: { ws1: "tab1" }, + focusedPaneIds: { tab1: "pane2" }, + }, + pane, + }), + ).toBe(false); + }); + + it("returns false when currentWorkspaceId is null", () => { + expect( + isPaneVisible({ + currentWorkspaceId: null, + tabsState: { + activeTabIds: { ws1: "tab1" }, + focusedPaneIds: { tab1: "pane1" }, + }, + pane, + }), + ).toBe(false); + }); + + it("returns false when tabsState is undefined", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: undefined, + pane, + }), + ).toBe(false); + }); + + it("returns false when activeTabIds is missing", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: { focusedPaneIds: { tab1: "pane1" } }, + pane, + }), + ).toBe(false); + }); + + it("returns false when focusedPaneIds is missing", () => { + expect( + isPaneVisible({ + currentWorkspaceId: "ws1", + tabsState: { activeTabIds: { ws1: "tab1" } }, + pane, + }), + ).toBe(false); + }); +}); + +describe("getNotificationTitle", () => { + const tabs = [ + { id: "tab1", name: "Tab 1", userTitle: "My Custom Title" }, + { id: "tab2", name: "Tab 2" }, + ]; + const panes = { + pane1: { name: "Pane 1" }, + pane2: { name: "Pane 2" }, + }; + + it("returns userTitle when available", () => { + expect(getNotificationTitle({ tabId: "tab1", tabs, panes })).toBe( + "My Custom Title", + ); + }); + + it("returns tab.name when no userTitle", () => { + expect(getNotificationTitle({ tabId: "tab2", tabs, panes })).toBe("Tab 2"); + }); + + it("returns pane.name when no tab found", () => { + expect(getNotificationTitle({ paneId: "pane1", tabs, panes })).toBe( + "Pane 1", + ); + }); + + it("returns Terminal as fallback", () => { + expect(getNotificationTitle({})).toBe("Terminal"); + }); + + it("trims whitespace from userTitle", () => { + const tabsWithWhitespace = [{ id: "t1", name: "Tab", userTitle: " " }]; + expect( + getNotificationTitle({ tabId: "t1", tabs: tabsWithWhitespace }), + ).toBe("Tab"); + }); +}); + +describe("getWorkspaceName", () => { + it("returns workspace.name when available", () => { + expect( + getWorkspaceName({ + workspace: { name: "My Workspace", worktreeId: null }, + }), + ).toBe("My Workspace"); + }); + + it("returns worktree.branch when no workspace name", () => { + expect( + getWorkspaceName({ + workspace: { name: null, worktreeId: "wt1" }, + worktree: { branch: "feature/test" }, + }), + ).toBe("feature/test"); + }); + + it("returns Workspace as fallback", () => { + expect(getWorkspaceName({})).toBe("Workspace"); + }); + + it("returns Workspace when all values are null", () => { + expect( + getWorkspaceName({ + workspace: { name: null, worktreeId: null }, + worktree: { branch: null }, + }), + ).toBe("Workspace"); + }); +}); diff --git a/apps/desktop/src/main/lib/notifications/utils.ts b/apps/desktop/src/main/lib/notifications/utils.ts new file mode 100644 index 00000000000..e8bb44423a6 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/utils.ts @@ -0,0 +1,109 @@ +/** + * Extracts the workspace ID from a hash-routed URL. + * + * The app uses hash routing, so URLs look like: + * - file:///path/to/app/index.html#/workspace/abc123 + * - file:///Users/foo/workspace/superset/dist/index.html#/workspace/abc123?foo=bar + * + * This function parses the hash portion to avoid matching /workspace/ in the file path. + */ +export function extractWorkspaceIdFromUrl(url: string): string | null { + try { + const hash = new URL(url).hash; + const match = hash.match(/\/workspace\/([^/?#]+)/); + return match?.[1] ?? null; + } catch { + return null; + } +} + +interface TabsState { + activeTabIds?: Record; + focusedPaneIds?: Record; +} + +interface PaneLocation { + workspaceId: string; + tabId: string; + paneId: string; +} + +/** + * Determines if a pane is currently visible to the user. + * + * A pane is visible when: + * 1. User is viewing the workspace containing the pane + * 2. The tab is the active tab in that workspace + * 3. The pane is the focused pane in that tab + */ +export function isPaneVisible({ + currentWorkspaceId, + tabsState, + pane, +}: { + currentWorkspaceId: string | null; + tabsState: TabsState | undefined; + pane: PaneLocation; +}): boolean { + if (!currentWorkspaceId || !tabsState) { + return false; + } + + const isViewingWorkspace = currentWorkspaceId === pane.workspaceId; + const isActiveTab = tabsState.activeTabIds?.[pane.workspaceId] === pane.tabId; + const isFocusedPane = tabsState.focusedPaneIds?.[pane.tabId] === pane.paneId; + + return isViewingWorkspace && isActiveTab && isFocusedPane; +} + +interface BaseTab { + id: string; + name: string; + userTitle?: string; +} + +interface Pane { + name: string; +} + +/** + * Derives a display title for a notification from tab/pane state. + * Priority: tab.userTitle > tab.name > pane.name > "Terminal" + */ +export function getNotificationTitle({ + tabId, + paneId, + tabs, + panes, +}: { + tabId?: string; + paneId?: string; + tabs?: BaseTab[]; + panes?: Record; +}): string { + const tab = tabId ? tabs?.find((t) => t.id === tabId) : undefined; + const pane = paneId ? panes?.[paneId] : undefined; + return tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal"; +} + +interface Workspace { + name: string | null; + worktreeId: string | null; +} + +interface Worktree { + branch: string | null; +} + +/** + * Derives a display name for a workspace, falling back through available names. + */ +export function getWorkspaceName({ + workspace, + worktree, +}: { + workspace?: Workspace | null; + worktree?: Worktree | null; +}): string { + return workspace?.name || worktree?.branch || "Workspace"; +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 2e1c502d039..e508b03124c 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -17,6 +17,12 @@ import { notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; +import { + extractWorkspaceIdFromUrl, + getNotificationTitle, + getWorkspaceName, + isPaneVisible, +} from "../lib/notifications/utils"; import { getInitialWindowBounds, loadWindowState, @@ -27,6 +33,28 @@ import { getWorkspaceRuntimeRegistry } from "../lib/workspace-runtime"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; +function getWorkspaceNameFromDb(workspaceId: string | undefined): string { + if (!workspaceId) return "Workspace"; + try { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + const worktree = workspace?.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : undefined; + return getWorkspaceName({ workspace, worktree }); + } catch (error) { + console.error("[notifications] Failed to get workspace name:", error); + return "Workspace"; + } +} + // Current window reference - updated on window create/close let currentWindow: BrowserWindow | null = null; @@ -96,77 +124,61 @@ export async function MainWindow() { // Only notify on Stop (completion) and PermissionRequest - not on Start if (event.eventType === "Start") return; - if (Notification.isSupported()) { - const isPermissionRequest = event.eventType === "PermissionRequest"; - - // Derive workspace name from workspaceId with safe fallbacks - let workspaceName = "Workspace"; - try { - if (event.workspaceId) { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, event.workspaceId)) - .get(); - const worktree = workspace?.worktreeId - ? localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get() - : 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; - 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, + // Skip notification if user is already viewing this pane (Slack pattern) + if ( + window.isFocused() && + event.workspaceId && + event.tabId && + event.paneId + ) { + const isVisible = isPaneVisible({ + currentWorkspaceId: extractWorkspaceIdFromUrl( + window.webContents.getURL(), + ), + tabsState: appState.data?.tabsState, + pane: { + workspaceId: event.workspaceId, + tabId: event.tabId, + paneId: event.paneId, + }, }); + if (isVisible) return; + } - playNotificationSound(); - - notification.on("click", () => { - window.show(); - window.focus(); - notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, { - paneId: event.paneId, - tabId: event.tabId, - workspaceId: event.workspaceId, - }); + if (!Notification.isSupported()) return; + + const workspaceName = getWorkspaceNameFromDb(event.workspaceId); + const title = getNotificationTitle({ + tabId: event.tabId, + paneId: event.paneId, + tabs: appState.data?.tabsState?.tabs, + panes: appState.data?.tabsState?.panes, + }); + + const isPermissionRequest = event.eventType === "PermissionRequest"; + 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(NOTIFICATION_EVENTS.FOCUS_TAB, { + paneId: event.paneId, + tabId: event.tabId, + workspaceId: event.workspaceId, }); + }); - notification.show(); - } + notification.show(); }, );