diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx index 5b9214f19f6..ec758ed625e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx @@ -15,6 +15,8 @@ interface WorkspaceHostRow { workspaceId: string; organizationId: string; hostId: string; + name: string; + branch: string; } interface HostNotificationSubscriberGroup { @@ -43,6 +45,8 @@ export function V2NotificationController() { workspaceId: v2Workspaces.id, organizationId: v2Workspaces.organizationId, hostId: v2Workspaces.hostId, + name: v2Workspaces.name, + branch: v2Workspaces.branch, })), [collections], ); @@ -118,6 +122,8 @@ function groupWorkspacesByHostUrl({ const group = groups.get(hostUrl) ?? []; group.push({ workspaceId: workspace.workspaceId, + workspaceName: + workspace.name.trim() || workspace.branch.trim() || "Workspace", paneLayout: paneLayoutsByWorkspaceId.get(workspace.workspaceId) ?? null, }); groups.set(hostUrl, group); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx index ef274e13b03..ff1390bd1dc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx @@ -16,6 +16,7 @@ import { export interface HostNotificationWorkspaceState { workspaceId: string; + workspaceName: string; paneLayout: WorkspaceState | null; } @@ -53,6 +54,7 @@ export function HostNotificationSubscriber({ if (!workspace) return; handleV2AgentLifecycleEvent({ workspaceId, + workspaceName: workspace.workspaceName, payload, paneLayout: workspace.paneLayout, volume, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts index 6892ddc5eb6..841d9c27f81 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -12,6 +12,7 @@ import { useV2NotificationStore, type V2NotificationSourceInput, } from "renderer/stores/v2-notifications"; +import { getV2NativeNotificationContent } from "./notificationContent"; import { isV2NotificationTargetVisible, resolveV2NotificationTarget, @@ -27,12 +28,14 @@ import { resolveV2AgentStatusTransition } from "./statusTransitions"; */ export function handleV2AgentLifecycleEvent({ workspaceId, + workspaceName, payload, paneLayout, volume, muted, }: { workspaceId: string; + workspaceName: string; payload: AgentLifecyclePayload; paneLayout: WorkspaceState | null | undefined; volume: number; @@ -61,7 +64,12 @@ export function handleV2AgentLifecycleEvent({ const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; void playRingtone({ ringtoneId, volume, muted }); - showNativeNotification({ payload, workspaceId, target }); + showNativeNotification({ + payload, + workspaceId, + workspaceName, + target, + }); } export function handleV2TerminalLifecycleEvent({ @@ -134,17 +142,18 @@ function shouldSuppress( function showNativeNotification({ payload, workspaceId, + workspaceName, target, }: { payload: AgentLifecyclePayload; workspaceId: string; + workspaceName: string; target: V2NotificationTarget; }): void { - const isPermission = payload.eventType === "PermissionRequest"; - const title = isPermission ? "Awaiting Response" : "Agent Complete"; - const body = isPermission - ? "Your agent needs input" - : "Your agent has finished"; + const { title, body } = getV2NativeNotificationContent({ + workspaceName, + payload, + }); void electronTrpcClient.notifications.showNative .mutate({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.test.ts new file mode 100644 index 00000000000..bbe72f856fe --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { getV2NativeNotificationContent } from "./notificationContent"; + +function payload( + overrides: Partial, +): AgentLifecyclePayload { + return { + eventType: "Stop", + terminalId: "terminal-1", + occurredAt: 1, + ...overrides, + }; +} + +describe("getV2NativeNotificationContent", () => { + it("uses the agent label in the title and workspace label in the body", () => { + expect( + getV2NativeNotificationContent({ + workspaceName: "Improve notifications", + payload: payload({ + agent: { agentId: "codex", sessionId: "session-1" }, + }), + }), + ).toEqual({ + title: "Codex - Complete", + body: "Improve notifications", + }); + }); + + it("uses needs-attention copy for permission requests", () => { + expect( + getV2NativeNotificationContent({ + workspaceName: "Improve notifications", + payload: payload({ + eventType: "PermissionRequest", + agent: { agentId: "claude" }, + }), + }), + ).toMatchObject({ + title: "Claude - Needs Attention", + body: "Improve notifications", + }); + }); + + it("falls back to generic labels", () => { + expect( + getV2NativeNotificationContent({ + workspaceName: " ", + payload: payload({ agent: { agentId: "droid" } }), + }), + ).toEqual({ + title: "Droid - Complete", + body: "Workspace", + }); + + expect( + getV2NativeNotificationContent({ + workspaceName: "", + payload: payload({ agent: undefined }), + }), + ).toMatchObject({ + title: "Agent - Complete", + body: "Workspace", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.ts new file mode 100644 index 00000000000..5f525b53407 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/notificationContent.ts @@ -0,0 +1,53 @@ +import { + BUILTIN_AGENT_LABELS, + type BuiltinAgentId, +} from "@superset/shared/agent-catalog"; +import type { + AgentIdentity, + AgentLifecyclePayload, +} from "@superset/workspace-client"; + +interface V2NativeNotificationContentOptions { + workspaceName: string; + payload: AgentLifecyclePayload; +} + +export function getV2NativeNotificationContent({ + workspaceName, + payload, +}: V2NativeNotificationContentOptions): { title: string; body: string } { + const agentLabel = getAgentLabel(payload.agent); + const action = + payload.eventType === "PermissionRequest" ? "Needs Attention" : "Complete"; + const workspaceLabel = cleanLabel(workspaceName) ?? "Workspace"; + + return { + title: `${agentLabel} - ${action}`, + body: workspaceLabel, + }; +} + +function getAgentLabel(agent: AgentIdentity | undefined): string { + const agentId = cleanLabel(agent?.agentId); + if (!agentId) return "Agent"; + if (agentId in BUILTIN_AGENT_LABELS) { + return BUILTIN_AGENT_LABELS[agentId as BuiltinAgentId]; + } + return humanizeIdentifier(agentId); +} + +function cleanLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function humanizeIdentifier(value: string): string { + const words = value + .replace(/^custom:/, "") + .split(/[-_:\s]+/) + .filter(Boolean); + if (words.length === 0) return "Agent"; + return words + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}