From f6aed52f458ae45022d8a82a013b40fe0e50f840 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 22 Apr 2026 23:44:16 -0700 Subject: [PATCH 01/17] save --- .../templates/notify-hook.template.sh | 32 ++++ .../useWorkspaceEvent/useWorkspaceEvent.ts | 21 ++- .../src/renderer/lib/ringtones/play.ts | 80 ++++++++++ .../src/renderer/lib/ringtones/urls.ts | 52 +++++++ .../hooks/useV2AgentHookListener/index.ts | 1 + .../useV2AgentHookListener/isPaneVisible.ts | 34 +++++ .../useV2AgentHookListener.ts | 88 +++++++++++ .../v2-workspace/$workspaceId/page.tsx | 3 + .../renderer/routes/_authenticated/layout.tsx | 7 + packages/host-service/src/app.ts | 1 + packages/host-service/src/events/event-bus.ts | 15 ++ packages/host-service/src/events/index.ts | 5 + .../host-service/src/events/map-event-type.ts | 51 +++++++ packages/host-service/src/events/types.ts | 14 ++ packages/host-service/src/terminal/env.ts | 13 ++ .../host-service/src/terminal/terminal.ts | 13 ++ .../src/trpc/router/notifications/index.ts | 1 + .../router/notifications/notifications.ts | 55 +++++++ .../host-service/src/trpc/router/router.ts | 2 + packages/host-service/src/types.ts | 2 + packages/workspace-client/src/index.ts | 1 + packages/workspace-client/src/lib/eventBus.ts | 34 ++++- ...60422-v2-notification-hooks-client-side.md | 137 ++++++++++++++++++ 23 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/ringtones/play.ts create mode 100644 apps/desktop/src/renderer/lib/ringtones/urls.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts create mode 100644 packages/host-service/src/events/map-event-type.ts create mode 100644 packages/host-service/src/trpc/router/notifications/index.ts create mode 100644 packages/host-service/src/trpc/router/notifications/notifications.ts create mode 100644 plans/20260422-v2-notification-hooks-client-side.md diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 925702abf4b..fcef2d9864c 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -71,6 +71,38 @@ if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 fi +# Escape backslashes and double quotes for safe JSON embedding. +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# v2: host-service tRPC endpoint. The renderer subscribes over the event +# bus and plays the ringtone. Preferred when both the URL and the PSK are +# provided by host-service's terminal env. +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_HOST_AGENT_HOOK_TOKEN" ]; then + PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" + + if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 1 --max-time 2 \ + -H "Authorization: Bearer $SUPERSET_HOST_AGENT_HOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 + else + curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 1 --max-time 2 \ + -H "Authorization: Bearer $SUPERSET_HOST_AGENT_HOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + > /dev/null 2>&1 + fi + exit 0 +fi + +# v1 fallback: electron localhost server. Used by v1 terminals and when +# host-service is unreachable from the agent's shell. # Timeouts prevent blocking agent completion if notification server is unresponsive if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts index 288c3ed1000..99e1c7109eb 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -1,4 +1,5 @@ import { + type AgentLifecyclePayload, type GitChangedPayload, getEventBus, } from "@superset/workspace-client"; @@ -24,11 +25,18 @@ export function useWorkspaceEvent( enabled?: boolean, ): void; export function useWorkspaceEvent( - type: "git:changed" | "fs:events", + type: "agent:lifecycle", + workspaceId: string, + callback: (payload: AgentLifecyclePayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "git:changed" | "fs:events" | "agent:lifecycle", workspaceId: string, callback: | ((event: FsWatchEvent) => void) - | ((payload: GitChangedPayload) => void), + | ((payload: GitChangedPayload) => void) + | ((payload: AgentLifecyclePayload) => void), enabled = true, ): void { const hostUrl = useWorkspaceHostUrl(workspaceId); @@ -52,6 +60,15 @@ export function useWorkspaceEvent( }, ); cleanups.push(removeListener, () => bus.unwatchFs(workspaceId)); + } else if (type === "agent:lifecycle") { + const removeListener = bus.on( + "agent:lifecycle", + workspaceId, + (_wid, payload) => { + (handler as (payload: AgentLifecyclePayload) => void)(payload); + }, + ); + cleanups.push(removeListener); } else { const removeListener = bus.on( "git:changed", diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts new file mode 100644 index 00000000000..c95d5a7f94a --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -0,0 +1,80 @@ +import { + CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, + getRingtoneById, +} from "shared/ringtones"; +import { builtInRingtoneUrls } from "./urls"; + +export interface PlayRingtoneOptions { + ringtoneId: string; + /** 0..100 — matches the existing `notificationVolume` setting shape. */ + volume: number; + muted: boolean; +} + +let audioPrimed = false; + +/** + * Some browsers block `audio.play()` until the user has interacted with the + * page. Wire this up once at app mount so the first pointerdown unlocks + * autoplay and subsequent hook events can play without a visible gesture. + */ +export function primeRingtoneAudioOnFirstGesture(): void { + if (audioPrimed || typeof window === "undefined") return; + const prime = () => { + audioPrimed = true; + const silent = new Audio(); + silent.muted = true; + silent.play().catch(() => { + // If the gesture somehow still can't unlock audio, we'll retry on + // the next one — listener is re-added below. + audioPrimed = false; + window.addEventListener("pointerdown", prime, { once: true }); + }); + window.removeEventListener("pointerdown", prime); + window.removeEventListener("keydown", prime); + }; + window.addEventListener("pointerdown", prime, { once: true }); + window.addEventListener("keydown", prime, { once: true }); +} + +/** + * Resolve the bundled audio URL for a ringtone id. Returns null for the + * custom-ringtone id (handled separately via host-service upload — not + * part of this MVP) and for unknown ids that aren't the default. + */ +function resolveRingtoneUrl(ringtoneId: string): string | null { + if (ringtoneId === CUSTOM_RINGTONE_ID) { + // Custom uploads aren't wired into renderer playback yet — fall back + // to the default so muted is the only way to get silence in v2. + return ( + builtInRingtoneUrls[ + getRingtoneById(DEFAULT_RINGTONE_ID)?.filename ?? "" + ] ?? null + ); + } + const ringtone = getRingtoneById(ringtoneId); + if (ringtone && builtInRingtoneUrls[ringtone.filename]) { + return builtInRingtoneUrls[ringtone.filename] ?? null; + } + const fallback = getRingtoneById(DEFAULT_RINGTONE_ID); + return fallback ? (builtInRingtoneUrls[fallback.filename] ?? null) : null; +} + +export async function playRingtone(opts: PlayRingtoneOptions): Promise { + if (opts.muted) return; + const volume = Math.max(0, Math.min(1, opts.volume / 100)); + if (volume === 0) return; + + const url = resolveRingtoneUrl(opts.ringtoneId); + if (!url) return; + + const audio = new Audio(url); + audio.volume = volume; + + try { + await audio.play(); + } catch (error) { + console.warn("[ringtone] autoplay blocked or failed:", error); + } +} diff --git a/apps/desktop/src/renderer/lib/ringtones/urls.ts b/apps/desktop/src/renderer/lib/ringtones/urls.ts new file mode 100644 index 00000000000..2280e260632 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/urls.ts @@ -0,0 +1,52 @@ +/** + * Vite-bundled URLs for each built-in ringtone .mp3. Keyed by the filenames + * declared in `shared/ringtones.ts`. Using `new URL(..., import.meta.url)` + * lets Vite emit hashed asset URLs in prod and serve the files in dev + * without copying them into `resources/public/`. + */ +export const builtInRingtoneUrls: Record = { + "shamisen.mp3": new URL( + "../../../resources/sounds/shamisen.mp3", + import.meta.url, + ).href, + "arcade.mp3": new URL( + "../../../resources/sounds/arcade.mp3", + import.meta.url, + ).href, + "ping.mp3": new URL( + "../../../resources/sounds/ping.mp3", + import.meta.url, + ).href, + "supersetquick.mp3": new URL( + "../../../resources/sounds/supersetquick.mp3", + import.meta.url, + ).href, + "supersetdoowap.mp3": new URL( + "../../../resources/sounds/supersetdoowap.mp3", + import.meta.url, + ).href, + "agentisdonewoman.mp3": new URL( + "../../../resources/sounds/agentisdonewoman.mp3", + import.meta.url, + ).href, + "codecompleteafrican.mp3": new URL( + "../../../resources/sounds/codecompleteafrican.mp3", + import.meta.url, + ).href, + "codecompleteafrobeat.mp3": new URL( + "../../../resources/sounds/codecompleteafrobeat.mp3", + import.meta.url, + ).href, + "codecompleteedm.mp3": new URL( + "../../../resources/sounds/codecompleteedm.mp3", + import.meta.url, + ).href, + "comebacktothecode.mp3": new URL( + "../../../resources/sounds/comebacktothecode.mp3", + import.meta.url, + ).href, + "shabalabadingdong.mp3": new URL( + "../../../resources/sounds/shabalabadingdong.mp3", + import.meta.url, + ).href, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts new file mode 100644 index 00000000000..06c3016a5bc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts @@ -0,0 +1 @@ +export { useV2AgentHookListener } from "./useV2AgentHookListener"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts new file mode 100644 index 00000000000..7ce41fc0237 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts @@ -0,0 +1,34 @@ +interface TabsState { + activeTabIds?: Record; + focusedPaneIds?: Record; +} + +interface PaneLocation { + workspaceId: string; + tabId: string; + paneId: string; +} + +/** + * Renderer-side mirror of + * apps/desktop/src/main/lib/notifications/utils.ts#isPaneVisible. Kept as a + * tiny local copy rather than pulled from `main/` to avoid crossing the + * renderer/main boundary for a pure data helper. + */ +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; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts new file mode 100644 index 00000000000..89e1119e84e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -0,0 +1,88 @@ +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { useCallback } from "react"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { playRingtone } from "renderer/lib/ringtones/play"; +import { useRingtoneStore } from "renderer/stores/ringtone"; +import { useTabsStore } from "renderer/stores/tabs"; +import { isPaneVisible } from "./isPaneVisible"; + +/** + * Listens for v2 agent lifecycle events over the host-service WebSocket and + * plays the selected ringtone in the renderer. Mirrors the v1 electron-main + * playback path (see apps/desktop/src/main/lib/notifications/notification-manager.ts) + * but runs client-side so it works when host-service is off-machine. + * + * Keeps v1 behavior: skip `Start`, suppress when the event's pane is visible + * and the window is focused, and honor the existing mute/volume settings. + */ +export function useV2AgentHookListener(workspaceId: string): void { + const { data: volume = 100 } = + electronTrpc.settings.getNotificationVolume.useQuery(); + const { data: muted = false } = + electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + + const handleEvent = useCallback( + (payload: AgentLifecyclePayload) => { + if (payload.eventType === "Start") return; + if (shouldSuppress(workspaceId, payload)) return; + + const ringtoneId = + useRingtoneStore.getState().selectedRingtoneId; + void playRingtone({ ringtoneId, volume, muted }); + + showNativeNotification(payload, workspaceId); + }, + [workspaceId, volume, muted], + ); + + useWorkspaceEvent("agent:lifecycle", workspaceId, handleEvent); +} + +function shouldSuppress( + workspaceId: string, + payload: AgentLifecyclePayload, +): boolean { + if (!payload.paneId || !payload.tabId) return false; + if (typeof document !== "undefined" && document.hidden) return false; + if (typeof window !== "undefined" && !document.hasFocus()) return false; + + const tabsState = useTabsStore.getState(); + return isPaneVisible({ + currentWorkspaceId: workspaceId, + tabsState: { + activeTabIds: tabsState.activeTabIds, + focusedPaneIds: tabsState.focusedPaneIds, + }, + pane: { + workspaceId, + tabId: payload.tabId, + paneId: payload.paneId, + }, + }); +} + +function showNativeNotification( + payload: AgentLifecyclePayload, + workspaceId: string, +): void { + if (typeof Notification === "undefined") return; + if (Notification.permission !== "granted") return; + + const isPermission = payload.eventType === "PermissionRequest"; + const title = isPermission ? "Awaiting Response" : "Agent Complete"; + const body = isPermission + ? "Your agent needs input" + : "Your agent has finished"; + + try { + new Notification(title, { + body, + tag: `${workspaceId}:${payload.paneId ?? payload.sessionId ?? "_"}`, + silent: true, + }); + } catch { + // Notification constructor can throw if the permission was revoked + // between the check and the call. Non-fatal. + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 1929105bafb..4b9105afc78 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -31,6 +31,7 @@ import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActio import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; +import { useV2AgentHookListener } from "./hooks/useV2AgentHookListener"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -69,6 +70,8 @@ function V2WorkspacePage() { const { terminalId, chatSessionId } = Route.useSearch(); const collections = useCollections(); + useV2AgentHookListener(workspaceId); + const { data: workspaces } = useLiveQuery( (q) => q diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 5c91c31670c..0df5235ef77 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -21,6 +21,7 @@ import { migrateHotkeyOverrides } from "renderer/hotkeys/migrate"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; import { dragDropManager } from "renderer/lib/dnd"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { primeRingtoneAudioOnFirstGesture } from "renderer/lib/ringtones/play"; import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showWorkspaceAutoNameWarningToast"; import { InitGitDialog } from "renderer/react-query/projects/InitGitDialog"; import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal"; @@ -74,6 +75,12 @@ function AuthenticatedLayout() { }); }, []); + // Unlock browser audio autoplay so v2 agent-hook sounds can play without + // a visible user gesture on each event. + useEffect(() => { + primeRingtoneAudioOnFirstGesture(); + }, []); + // Update workspace-run pane state on terminal exit electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 3b53d7bd67f..1e79ed5d6e3 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -118,6 +118,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { api, db, runtime, + eventBus, organizationId: config.organizationId, isAuthenticated, } as Record; diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index d1a5d75fa31..47c594d92e5 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -122,6 +122,21 @@ export class EventBus { } } + /** + * Fan out an agent lifecycle event (hook completion) to all connected + * clients. The workspace-client filters by `workspaceId` on the receiving + * side; we broadcast indiscriminately here to match the existing + * `git:changed` pattern. + */ + broadcastAgentLifecycle( + message: Omit< + Extract, + "type" + >, + ): void { + this.broadcast({ type: "agent:lifecycle", ...message }); + } + private startFsWatch( socket: WsSocket, state: ClientState, diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts index e64d91df2bd..a05a77f04b8 100644 --- a/packages/host-service/src/events/index.ts +++ b/packages/host-service/src/events/index.ts @@ -1,5 +1,10 @@ export { EventBus, registerEventBusRoute } from "./event-bus"; +export { + type AgentLifecycleEventType, + mapEventType, +} from "./map-event-type"; export type { + AgentLifecycleMessage, ClientMessage, EventBusErrorMessage, FsEventsMessage, diff --git a/packages/host-service/src/events/map-event-type.ts b/packages/host-service/src/events/map-event-type.ts new file mode 100644 index 00000000000..8e69631fc94 --- /dev/null +++ b/packages/host-service/src/events/map-event-type.ts @@ -0,0 +1,51 @@ +export type AgentLifecycleEventType = "Start" | "Stop" | "PermissionRequest"; + +export function mapEventType( + eventType: string | undefined, +): AgentLifecycleEventType | null { + if (!eventType) { + return null; + } + if ( + eventType === "Start" || + eventType === "SessionStart" || + eventType === "UserPromptSubmit" || + eventType === "PostToolUse" || + eventType === "PostToolUseFailure" || + eventType === "BeforeAgent" || + eventType === "AfterTool" || + eventType === "sessionStart" || + eventType === "session_start" || + eventType === "userPromptSubmitted" || + eventType === "user_prompt_submit" || + eventType === "postToolUse" || + eventType === "post_tool_use" || + eventType === "task_started" + ) { + return "Start"; + } + if ( + eventType === "PermissionRequest" || + eventType === "Notification" || + eventType === "PreToolUse" || + eventType === "preToolUse" || + eventType === "pre_tool_use" || + eventType === "exec_approval_request" || + eventType === "apply_patch_approval_request" || + eventType === "request_user_input" + ) { + return "PermissionRequest"; + } + if ( + eventType === "Stop" || + eventType === "stop" || + eventType === "agent-turn-complete" || + eventType === "AfterAgent" || + eventType === "sessionEnd" || + eventType === "session_end" || + eventType === "task_complete" + ) { + return "Stop"; + } + return null; +} diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 47186f700d6..4553142ad21 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -1,4 +1,5 @@ import type { FsWatchEvent } from "@superset/workspace-fs/host"; +import type { AgentLifecycleEventType } from "./map-event-type"; // ── Server → Client ──────────────────────────────────────────────── @@ -20,6 +21,18 @@ export interface GitChangedMessage { paths?: string[]; } +export interface AgentLifecycleMessage { + type: "agent:lifecycle"; + workspaceId: string; + eventType: AgentLifecycleEventType; + paneId?: string; + tabId?: string; + sessionId?: string; + hookSessionId?: string; + resourceId?: string; + occurredAt: number; +} + export interface EventBusErrorMessage { type: "error"; message: string; @@ -28,6 +41,7 @@ export interface EventBusErrorMessage { export type ServerMessage = | FsEventsMessage | GitChangedMessage + | AgentLifecycleMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index 135594ba068..7168f80e40d 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -112,6 +112,10 @@ interface BuildV2TerminalEnvParams { supersetEnv: "development" | "production"; agentHookPort: string; agentHookVersion: string; + /** tRPC URL for the host-service notifications.hook mutation. */ + hostAgentHookUrl: string; + /** PSK the agent attaches as `Authorization: Bearer `. */ + hostAgentHookToken: string; } /** @@ -135,6 +139,8 @@ export function buildV2TerminalEnv( supersetEnv, agentHookPort, agentHookVersion, + hostAgentHookUrl, + hostAgentHookToken, } = params; // Defense in depth — baseEnv is pre-stripped at init, but strip again @@ -158,6 +164,13 @@ export function buildV2TerminalEnv( env.SUPERSET_ENV = supersetEnv; env.SUPERSET_AGENT_HOOK_PORT = agentHookPort; env.SUPERSET_AGENT_HOOK_VERSION = agentHookVersion; + // v2 — agent posts to host-service so the renderer can play the sound + // client-side. Set only when both URL and token are available; the + // notify-hook script falls back to the electron endpoint otherwise. + if (hostAgentHookUrl && hostAgentHookToken) { + env.SUPERSET_HOST_AGENT_HOOK_URL = hostAgentHookUrl; + env.SUPERSET_HOST_AGENT_HOOK_TOKEN = hostAgentHookToken; + } if (supersetHomeDir) { env.SUPERSET_HOME_DIR = supersetHomeDir; diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index ce6f28aaff8..1fc212f338f 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -30,6 +30,17 @@ export function parseThemeType( return value === "dark" || value === "light" ? value : undefined; } +/** + * Build the host-service tRPC URL for the v2 agent hook. The agent shell + * script POSTs to this; host-service fans out on the event bus so the + * renderer (web or electron) can play the finish sound. + */ +function getHostAgentHookUrl(): string { + const port = process.env.HOST_SERVICE_PORT || process.env.PORT; + if (!port) return ""; + return `http://127.0.0.1:${port}/trpc/notifications.hook`; +} + type TerminalClientMessage = | { type: "input"; data: string } | { type: "resize"; cols: number; rows: number } @@ -266,6 +277,8 @@ export function createTerminalSessionInternal({ process.env.NODE_ENV === "development" ? "development" : "production", agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", + hostAgentHookUrl: getHostAgentHookUrl(), + hostAgentHookToken: process.env.HOST_SERVICE_SECRET || "", }); let pty: IPty; diff --git a/packages/host-service/src/trpc/router/notifications/index.ts b/packages/host-service/src/trpc/router/notifications/index.ts new file mode 100644 index 00000000000..fab46812349 --- /dev/null +++ b/packages/host-service/src/trpc/router/notifications/index.ts @@ -0,0 +1 @@ +export { notificationsRouter } from "./notifications"; diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts new file mode 100644 index 00000000000..4284778442b --- /dev/null +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { mapEventType } from "../../../events"; +import { protectedProcedure, router } from "../../index"; + +/** + * Input shape matches the v1 `/hook/complete` query-string contract so the + * agent shell hook (notify-hook.template.sh) can point at either endpoint + * during the v1→v2 transition. Fields are optional because different agent + * runtimes emit different subsets. + */ +const hookInput = z.object({ + paneId: z.string().optional(), + tabId: z.string().optional(), + workspaceId: z.string().optional(), + sessionId: z.string().optional(), + hookSessionId: z.string().optional(), + resourceId: z.string().optional(), + eventType: z.string().optional(), + env: z.string().optional(), + version: z.string().optional(), +}); + +export const notificationsRouter = router({ + /** + * Agent lifecycle hook. The agent shell script POSTs here on + * session-start / permission-request / task-complete events. We normalize + * the event type and fan out over the WebSocket event bus so clients + * (desktop renderer, web) can play the finish sound themselves. + */ + hook: protectedProcedure + .input(hookInput) + .mutation(async ({ ctx, input }) => { + const eventType = mapEventType(input.eventType); + if (!eventType) { + return { success: true, ignored: true as const }; + } + + if (!input.workspaceId) { + return { success: true, ignored: true as const }; + } + + ctx.eventBus.broadcastAgentLifecycle({ + workspaceId: input.workspaceId, + eventType, + paneId: input.paneId, + tabId: input.tabId, + sessionId: input.sessionId, + hookSessionId: input.hookSessionId, + resourceId: input.resourceId, + occurredAt: Date.now(), + }); + + return { success: true, ignored: false as const }; + }), +}); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 494ef0ecde5..964b323775f 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -6,6 +6,7 @@ import { gitRouter } from "./git"; import { githubRouter } from "./github"; import { healthRouter } from "./health"; import { hostRouter } from "./host"; +import { notificationsRouter } from "./notifications"; import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; import { terminalRouter } from "./terminal"; @@ -21,6 +22,7 @@ export const appRouter = router({ git: gitRouter, github: githubRouter, cloud: cloudRouter, + notifications: notificationsRouter, pullRequests: pullRequestsRouter, project: projectRouter, terminal: terminalRouter, diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 42579438e80..148b1c696b0 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -2,6 +2,7 @@ import type { Octokit } from "@octokit/rest"; import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; import type { HostDb } from "./db"; +import type { EventBus } from "./events"; import type { ChatRuntimeManager } from "./runtime/chat"; import type { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitFactory } from "./runtime/git"; @@ -21,6 +22,7 @@ export interface HostServiceContext { api: ApiClient; db: HostDb; runtime: HostServiceRuntime; + eventBus: EventBus; organizationId: string; isAuthenticated: boolean; } diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index 60b32c3bbde..3b6cbb0d393 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -1,6 +1,7 @@ export { useEventBus } from "./hooks/useEventBus"; export { useGitChangeEvents } from "./hooks/useGitChangeEvents"; export { + type AgentLifecyclePayload, type EventBusHandle, type GitChangedPayload, getEventBus, diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index b82cb6df283..a5e9fc0e4d3 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -1,10 +1,11 @@ import type { + AgentLifecycleEventType, ClientMessage, ServerMessage, } from "@superset/host-service/events"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; -type EventType = "fs:events" | "git:changed"; +type EventType = "fs:events" | "git:changed" | "agent:lifecycle"; interface FsEventsPayload { events: FsWatchEvent[]; @@ -18,11 +19,23 @@ export interface GitChangedPayload { paths?: string[]; } +export interface AgentLifecyclePayload { + eventType: AgentLifecycleEventType; + paneId?: string; + tabId?: string; + sessionId?: string; + hookSessionId?: string; + resourceId?: string; + occurredAt: number; +} + type EventListener = T extends "fs:events" ? (workspaceId: string, payload: FsEventsPayload) => void : T extends "git:changed" ? (workspaceId: string, payload: GitChangedPayload) => void - : never; + : T extends "agent:lifecycle" + ? (workspaceId: string, payload: AgentLifecyclePayload) => void + : never; interface ListenerEntry { type: EventType; @@ -75,7 +88,9 @@ function handleMessage(state: ConnectionState, data: unknown): void { if (entry.type !== message.type) continue; const workspaceId = - message.type === "fs:events" || message.type === "git:changed" + message.type === "fs:events" || + message.type === "git:changed" || + message.type === "agent:lifecycle" ? message.workspaceId : null; @@ -95,6 +110,19 @@ function handleMessage(state: ConnectionState, data: unknown): void { (entry.callback as EventListener<"git:changed">)(message.workspaceId, { paths: message.paths, }); + } else if (message.type === "agent:lifecycle") { + (entry.callback as EventListener<"agent:lifecycle">)( + message.workspaceId, + { + eventType: message.eventType, + paneId: message.paneId, + tabId: message.tabId, + sessionId: message.sessionId, + hookSessionId: message.hookSessionId, + resourceId: message.resourceId, + occurredAt: message.occurredAt, + }, + ); } } } diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md new file mode 100644 index 00000000000..dd853d64dab --- /dev/null +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -0,0 +1,137 @@ +# V2 Notification Hooks: Client-Side Playback + +Goal: play the agent finish sound on the client (web renderer / electron renderer) instead of the electron main process, so notifications work when the host-service is off-machine. Keep v1 feature parity: 11 bundled ringtones + single custom slot per user, volume/mute settings, pane-visibility suppression. + +## Principles + +- **One code path** for web and electron renderer. Audio plays via `HTMLAudioElement` in the renderer; electron main no longer plays sound. +- **Host-service is the hook ingress.** The agent's `/hook/complete` endpoint moves off electron onto the host-service. Works whether host-service is on the user's machine or remote. +- **Same UX as v1.** Single ringtone per user, applied to all hook events. No event-specific sounds, no randomized variants, no multi-slot custom library — those are out of scope. +- **Feature-parity before parity-plus.** Ship built-ins first, port custom ringtone second, add nothing else. + +## Non-goals + +- Event-specific sounds (save / format / chat-received). Not v1 behavior. +- Randomized sound variants (`responseReceived1..4.mp3` style). +- Multi-slot custom ringtone library. Keep v1's single-slot model. +- Per-workspace ringtone override. +- Native OS integrations beyond `Notification` + `HTMLAudioElement` (no dock bounce, no tray flash). Add later if asked. + +## Architecture + +``` +agent ──POST /hook/complete──▶ host-service + │ + ├── persist event (optional, for reconnect replay) + └── broadcast via existing WebSocket EventBus + │ + ▼ + web client / electron renderer + │ + ├── decide (focus + visibility + settings) + ├── dedup across tabs (event-id) + └── play ringtone + show Notification +``` + +Key move: the electron localhost hook server (`apps/desktop/src/main/lib/notifications/server.ts`) is retired for v2. Electron main does no sound work. + +## Phase 1: Host-service hook ingress + +Status: next + +- Add `POST /hook/complete` to host-service. Port the event-shape validation and normalization from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (Start / Stop / PermissionRequest) into a shared module — plan to import from `packages/shared` or duplicate per v1-v2 duplication memory. +- Add `agent:lifecycle` channel to the existing WebSocket event bus (`packages/host-service/src/events/event-bus.ts`, alongside `git:changed` / `fs:events`). Payload: `{ eventId, workspaceId, paneId, tabId, sessionId, type, occurredAt }`. +- Broadcast is scoped by `workspaceId` → only subscribers authorized for that workspace receive the event. +- Auth: require the workspace token the agent already uses. The endpoint is reachable over the network, so unlike v1's `127.0.0.1`-bound server it must authenticate. +- Hook protocol version header preserved (v1 uses version 2). + +Exit criteria: +- Agent posting to host-service `/hook/complete` produces a WebSocket broadcast on `agent:lifecycle`. +- Auth rejects unknown tokens; `curl` from another workspace cannot spoof. +- Unit tests cover the map-event-type logic (port from `server.test.ts`). + +## Phase 2: Web client playback + +Status: next + +- Bundle the 11 v1 ringtones as static assets under `apps/web/public/ringtones/`. Copy the files from `apps/desktop/src/resources/sounds/`. `shared/ringtones.ts` (the registry in `apps/desktop/src/shared/ringtones.ts`) moves to `packages/shared/src/ringtones/` so both desktop and web import the same metadata. +- `apps/web/src/lib/ringtones/play.ts`: + - `primeAudioOnFirstGesture()` — attach a one-shot `pointerdown` listener that plays a silent `HTMLAudioElement` to unlock autoplay. Call once at app mount. + - `playRingtone({ ringtoneId, volume, muted })` — if muted or volume 0, no-op. Otherwise `new Audio("/ringtones/")`, set `volume`, `play()`. Fall back silently if `play()` rejects (autoplay blocked before gesture). +- `apps/web/src/hooks/useAgentHookListener.ts`: + - Subscribe to `agent:lifecycle` via existing WebSocket client. + - Suppression rule (v1 parity, see `apps/desktop/src/main/lib/notifications/notification-manager.ts:115`): if the event's pane is visible *and* the window is focused, do not play. + - Tab dedup: track a small LRU `Set` in `sessionStorage` keyed by time bucket so only one tab plays per event. Upgrade to `BroadcastChannel` leader election if the LRU proves janky. + - On play: call `playRingtone(...)` + `new Notification(...)` in parallel. +- Settings UI: list 11 built-ins, preview-play button per row, volume slider, mute toggle. Reuse the v1 renderer's ringtone picker component if it's portable; otherwise build the web version against the same `ringtones` registry. + +Exit criteria: +- Posting a hook event to host-service plays the selected built-in ringtone in an open web tab. +- Muted / volume 0 produces no sound. +- Visible + focused pane suppresses sound (matches v1). +- Two tabs open → sound plays once. + +## Phase 3: Prefs in Postgres + +Status: next + +- Add columns to the relevant `userSettings` table in `packages/db/src/schema/`: + - `selected_ringtone_id text` (nullable → means default `arcade`) + - `notification_volume real` default 0.5 + - `notification_sounds_muted boolean` default false +- tRPC router `notifications.settings.{get,update}` in `packages/trpc`, consumed by web and electron renderer. +- Electron v2 renderer: read from the same tRPC path instead of local-db. V1 local-db read stays for v1 UI only (per `project_v1_sunset` memory; v1 dies, don't evolve). + +Exit criteria: +- A user's ringtone choice syncs across devices. +- Migration generated via drizzle-kit (follow DB migration rules in AGENTS.md — spin up Neon branch, don't hand-edit migrations). + +## Phase 4: Custom ringtone (single slot) + +Status: later + +- Schema: reuse `userSettings.selected_ringtone_id` — value `"custom"` means "use the user's custom upload." A parallel column `custom_ringtone_r2_key text` (nullable) stores the upload location. +- Host-service endpoint `POST /ringtones/custom` — accept a single file (multipart), validate size + extension (v1 rules: ≤20MB, `.mp3`/`.wav`/`.ogg`), stream to R2 at `ringtones/custom/`, upsert `custom_ringtone_r2_key` + display name. +- Host-service endpoint `GET /ringtones/custom/url` — returns a short-lived signed URL. +- Client: `getRingtoneBlob(id)` reads blob from IndexedDB; on miss, fetches signed URL → caches → returns. `playRingtone` uses the blob via `URL.createObjectURL`. +- Settings UI: "Upload custom sound" button + preview + remove. Replaces existing custom on upload (v1 semantics — single slot). + +Exit criteria: +- User uploads a `.mp3` in web, selects it, triggers a hook event → correct sound plays. +- Uploading replaces the previous custom ringtone. +- Custom ringtone selection persists and syncs across devices. + +## Phase 5: v1 → v2 custom ringtone migration + +Status: later + +- One-shot on first v2 boot of desktop: if `~/.superset/assets/ringtones/notification-custom.{ext}` exists, POST it to host-service `/ringtones/custom`, then delete the local file. +- Read display name from sibling `notification-custom.json`. +- Migration flag in local-db so this runs exactly once per device. + +Exit criteria: +- Existing users with a v1 custom ringtone find it preserved in v2 without any manual action. + +## Phase 6: Retire electron main playback + +Status: later (after web + desktop v2 stable) + +- Remove `apps/desktop/src/main/lib/notifications/server.ts` (localhost hook server). +- Remove `apps/desktop/src/main/lib/play-sound.ts` (`afplay`/`paplay` shell-outs). +- Remove `apps/desktop/src/main/lib/custom-ringtones.ts` (local FS). +- Keep v1 UI paths that still use these working until the v1 sunset (per `project_v1_sunset`). If v1 is already off by this point, delete outright. + +Exit criteria: +- No electron main code plays audio. +- Electron renderer and web renderer share one sound path. + +## Risks and open questions + +- **Missed events while disconnected.** WebSocket is lossy on reconnect. If "I missed 3 completions" matters, persist hook events in host-service and replay with a `since` cursor. If not, fire-and-forget is fine — propose fire-and-forget, revisit if complaints. +- **Autoplay policy.** Mobile Safari is stricter about unprimed audio than desktop Chrome. If the user's first interaction is returning to a backgrounded tab after a hook fires, the sound may be blocked. The `Notification` toast still fires, which degrades gracefully. +- **Off-machine host-service + desktop.** The electron renderer talks to a remote host-service exactly like a browser does — no new IPC needed. But the hook endpoint is now over the network, so the hook token must not leak (scope it per-workspace, short-lived). +- **Multi-device simultaneous play.** If a user has web + desktop open on the same workspace, both play. Cross-device dedup is out of scope — a notification on two devices is arguably correct behavior, same as email. + +## Sequencing + +Phases 1 + 2 + 3 are the minimum to declare "client-side playback working with v1 parity minus custom ringtone." Ship those together. Phase 4 + 5 come after, gated on whether anyone actually used the v1 custom-upload feature (worth checking telemetry before investing in R2). From 7767b729fc31e596678e43dd32bc5e84398e339d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:24:22 -0700 Subject: [PATCH 02/17] feat(desktop): wire v2 agent-hook sidebar status + terminalId payload v2 terminals don't carry SUPERSET_PANE_ID, only SUPERSET_TERMINAL_ID, and agent payloads send empty strings for missing fields rather than omitting them. The sidebar status writer now threads terminalId through the whole pipeline (shell hook -> host-service -> event bus -> renderer) and falls back to terminalId/sessionId/hookSessionId as the store key when paneId is blank. Empty strings are now treated as missing (was using ??, now uses a firstNonBlank helper). Adds a workspace-level v2 pane-status store so the dashboard sidebar can render the same working/permission/review indicator the v1 sidebar did, and clears attention statuses when the user views the workspace. Also logs each hop of the pipeline so future debugging doesn't require guessing where the chain breaks. --- .../templates/notify-hook.template.sh | 2 +- .../src/renderer/lib/ringtones/urls.ts | 12 +- .../DashboardSidebarWorkspaceItem.tsx | 7 + .../DashboardSidebarExpandedWorkspaceRow.tsx | 5 +- .../useV2AgentHookListener/isPaneVisible.ts | 6 +- .../useV2AgentHookListener.ts | 120 +++++++++++++++++- .../v2-workspace/$workspaceId/page.tsx | 16 +++ .../renderer/stores/v2-pane-status/index.ts | 4 + .../renderer/stores/v2-pane-status/store.ts | 100 +++++++++++++++ bun.lock | 2 +- packages/host-service/src/events/event-bus.ts | 10 +- packages/host-service/src/events/types.ts | 1 + packages/host-service/src/terminal/env.ts | 13 +- .../router/notifications/notifications.ts | 59 +++++---- packages/workspace-client/src/lib/eventBus.ts | 10 ++ 15 files changed, 318 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/renderer/stores/v2-pane-status/index.ts create mode 100644 apps/desktop/src/renderer/stores/v2-pane-status/store.ts diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index fcef2d9864c..56a15eb73d2 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -80,7 +80,7 @@ json_escape() { # bus and plays the ringtone. Preferred when both the URL and the PSK are # provided by host-service's terminal env. if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_HOST_AGENT_HOOK_TOKEN" ]; then - PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" + PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ diff --git a/apps/desktop/src/renderer/lib/ringtones/urls.ts b/apps/desktop/src/renderer/lib/ringtones/urls.ts index 2280e260632..02de089f8b2 100644 --- a/apps/desktop/src/renderer/lib/ringtones/urls.ts +++ b/apps/desktop/src/renderer/lib/ringtones/urls.ts @@ -9,14 +9,10 @@ export const builtInRingtoneUrls: Record = { "../../../resources/sounds/shamisen.mp3", import.meta.url, ).href, - "arcade.mp3": new URL( - "../../../resources/sounds/arcade.mp3", - import.meta.url, - ).href, - "ping.mp3": new URL( - "../../../resources/sounds/ping.mp3", - import.meta.url, - ).href, + "arcade.mp3": new URL("../../../resources/sounds/arcade.mp3", import.meta.url) + .href, + "ping.mp3": new URL("../../../resources/sounds/ping.mp3", import.meta.url) + .href, "supersetquick.mp3": new URL( "../../../resources/sounds/supersetquick.mp3", import.meta.url, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 750c00c58c8..d2a35d21c52 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,6 +1,10 @@ import { useNavigate } from "@tanstack/react-router"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; +import { + selectWorkspaceStatus, + useV2PaneStatusStore, +} from "renderer/stores/v2-pane-status"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; @@ -35,6 +39,7 @@ export function DashboardSidebarWorkspaceItem({ creationStatus, } = workspace; const diffStats = useDiffStats(id); + const workspaceStatus = useV2PaneStatusStore(selectWorkspaceStatus(id)); const { cancelRename, handleClick, @@ -88,6 +93,7 @@ export function DashboardSidebarWorkspaceItem({ hostType={hostType} hostIsOnline={hostIsOnline} isActive={isActive} + workspaceStatus={workspaceStatus} onClick={isPending ? handlePendingClick : handleClick} creationStatus={creationStatus} disabled={isPending} @@ -154,6 +160,7 @@ export function DashboardSidebarWorkspaceItem({ renameValue={renameValue} shortcutLabel={shortcutLabel} diffStats={isPending ? null : diffStats} + workspaceStatus={workspaceStatus} onClick={isPending ? handlePendingClick : handleClick} onDoubleClick={isPending ? undefined : startRename} onDeleteClick={() => setIsDeleteDialogOpen(true)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 058c6d27f02..f2ad264f907 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -11,6 +11,7 @@ import { HiMiniXMark } from "react-icons/hi2"; import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; +import type { ActivePaneStatus } from "shared/tabs-types"; import type { DashboardSidebarWorkspace } from "../../../../types"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; @@ -25,6 +26,7 @@ interface DashboardSidebarExpandedWorkspaceRowProps renameValue: string; shortcutLabel?: string; diffStats: DiffStats | null; + workspaceStatus?: ActivePaneStatus | null; onClick?: () => void; onDoubleClick?: () => void; onDeleteClick: () => void; @@ -45,6 +47,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< renameValue, shortcutLabel, diffStats, + workspaceStatus = null, onClick, onDoubleClick, onDeleteClick, @@ -126,7 +129,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< hostIsOnline={hostIsOnline} isActive={isActive} variant="expanded" - workspaceStatus={null} + workspaceStatus={workspaceStatus} creationStatus={creationStatus} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts index 7ce41fc0237..5e51ca8d074 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts @@ -26,9 +26,7 @@ export function isPaneVisible({ }): 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; + const isActiveTab = tabsState.activeTabIds?.[pane.workspaceId] === pane.tabId; + const isFocusedPane = tabsState.focusedPaneIds?.[pane.tabId] === pane.paneId; return isViewingWorkspace && isActiveTab && isFocusedPane; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 89e1119e84e..a9ba1102ef4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -5,16 +5,20 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; import { useRingtoneStore } from "renderer/stores/ringtone"; import { useTabsStore } from "renderer/stores/tabs"; +import { useV2PaneStatusStore } from "renderer/stores/v2-pane-status"; import { isPaneVisible } from "./isPaneVisible"; /** - * Listens for v2 agent lifecycle events over the host-service WebSocket and + * Listens for v2 agent lifecycle events over the host-service WebSocket, + * updates pane status indicators (working/review/permission/idle) and * plays the selected ringtone in the renderer. Mirrors the v1 electron-main * playback path (see apps/desktop/src/main/lib/notifications/notification-manager.ts) + * plus the v1 sidebar-status path (renderer/stores/tabs/useAgentHookListener.ts), * but runs client-side so it works when host-service is off-machine. * - * Keeps v1 behavior: skip `Start`, suppress when the event's pane is visible - * and the window is focused, and honor the existing mute/volume settings. + * Keeps v1 behavior: skip `Start` for sound, suppress when the event's + * pane is visible and the window is focused, and honor the existing + * mute/volume settings. */ export function useV2AgentHookListener(workspaceId: string): void { const { data: volume = 100 } = @@ -24,11 +28,28 @@ export function useV2AgentHookListener(workspaceId: string): void { const handleEvent = useCallback( (payload: AgentLifecyclePayload) => { + console.log("[useV2AgentHookListener] handleEvent", { + workspaceId, + eventType: payload.eventType, + paneId: payload.paneId, + tabId: payload.tabId, + }); + updatePaneStatus(workspaceId, payload); + if (payload.eventType === "Start") return; - if (shouldSuppress(workspaceId, payload)) return; + const suppress = shouldSuppress(workspaceId, payload); + console.log("[useV2AgentHookListener] suppress check", { + suppress, + eventType: payload.eventType, + }); + if (suppress) return; - const ringtoneId = - useRingtoneStore.getState().selectedRingtoneId; + const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; + console.log("[useV2AgentHookListener] playing ringtone", { + ringtoneId, + volume, + muted, + }); void playRingtone({ ringtoneId, volume, muted }); showNativeNotification(payload, workspaceId); @@ -39,6 +60,93 @@ export function useV2AgentHookListener(workspaceId: string): void { useWorkspaceEvent("agent:lifecycle", workspaceId, handleEvent); } +/** + * Writes pane agent-lifecycle status into the v2 pane-status store so the + * dashboard sidebar icon can pick it up. V2 panes are not tracked in the + * v1 `useTabsStore`, so this is its own source of truth. + * + * The Stop transition mirrors v1 (useAgentHookListener.ts): clear to idle + * when the user is currently looking at this workspace (they'll see the + * result immediately); otherwise mark review so the sidebar surfaces it. + */ +function updatePaneStatus( + workspaceId: string, + payload: AgentLifecyclePayload, +): void { + // V2 terminals don't have a `paneId` (those live in the client-side + // panes store); fall back to terminalId / sessionId / hookSessionId as + // the unique key. The sidebar selector only filters on workspaceId so + // any non-empty unique id per running agent is fine — we just need + // SOMETHING to distinguish concurrent agents in the same workspace. + // + // Agent payloads frequently send empty strings (""), not missing + // fields, so `??` is wrong here — use a blank-string coalesce. + const paneId = firstNonBlank( + payload.paneId, + payload.terminalId, + payload.sessionId, + payload.hookSessionId, + payload.resourceId, + ); + if (!paneId) { + console.log( + "[useV2AgentHookListener] updatePaneStatus skipped — no identifier", + payload, + ); + return; + } + const store = useV2PaneStatusStore.getState(); + + if (payload.eventType === "Start") { + console.log("[useV2AgentHookListener] setPaneStatus working", { paneId }); + store.setPaneStatus(paneId, workspaceId, "working"); + return; + } + + if (payload.eventType === "PermissionRequest") { + console.log("[useV2AgentHookListener] setPaneStatus permission", { + paneId, + }); + store.setPaneStatus(paneId, workspaceId, "permission"); + return; + } + + if (payload.eventType === "Stop") { + const prev = store.statuses[paneId]?.status; + const viewing = isCurrentWorkspace(workspaceId); + const nextStatus = prev === "permission" || viewing ? "idle" : "review"; + console.log("[useV2AgentHookListener] Stop -> transition", { + paneId, + prev, + viewing, + nextStatus, + }); + if (nextStatus === "idle") { + store.clearPaneStatus(paneId); + } else { + store.setPaneStatus(paneId, workspaceId, nextStatus); + } + } +} + +function firstNonBlank( + ...values: (string | undefined | null)[] +): string | null { + for (const v of values) { + if (v && v.length > 0) return v; + } + return null; +} + +function isCurrentWorkspace(workspaceId: string): boolean { + try { + const match = window.location.hash.match(/\/workspace\/([^/?#]+)/); + return match?.[1] === workspaceId; + } catch { + return false; + } +} + function shouldSuppress( workspaceId: string, payload: AgentLifecyclePayload, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 09be00aad88..f664242e637 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -16,6 +16,7 @@ import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; +import { useV2PaneStatusStore } from "renderer/stores/v2-pane-status"; import { toAbsoluteWorkspacePath, toRelativeWorkspacePath, @@ -72,6 +73,7 @@ function V2WorkspacePage() { const collections = useCollections(); useV2AgentHookListener(workspaceId); + useClearPaneAttentionOnView(workspaceId); const { data: workspaces } = useLiveQuery( (q) => @@ -101,6 +103,20 @@ function V2WorkspacePage() { ); } +/** + * Clear "review" statuses for this workspace whenever the user is viewing + * the workspace page. Mirrors v1's `resetWorkspaceStatus` effect: being + * on the page counts as attention, so the sidebar indicator should clear. + */ +function useClearPaneAttentionOnView(workspaceId: string): void { + const clearWorkspaceAttention = useV2PaneStatusStore( + (s) => s.clearWorkspaceAttention, + ); + useEffect(() => { + clearWorkspaceAttention(workspaceId); + }, [workspaceId, clearWorkspaceAttention]); +} + function WorkspaceContent({ projectId, workspaceId, diff --git a/apps/desktop/src/renderer/stores/v2-pane-status/index.ts b/apps/desktop/src/renderer/stores/v2-pane-status/index.ts new file mode 100644 index 00000000000..53556b42468 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-pane-status/index.ts @@ -0,0 +1,4 @@ +export { + selectWorkspaceStatus, + useV2PaneStatusStore, +} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-pane-status/store.ts b/apps/desktop/src/renderer/stores/v2-pane-status/store.ts new file mode 100644 index 00000000000..f7af997c6ab --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-pane-status/store.ts @@ -0,0 +1,100 @@ +import { getHighestPriorityStatus, type PaneStatus } from "shared/tabs-types"; +import { create } from "zustand"; + +/** + * Per-pane status for v2 panes. V2 panes live in the `@superset/panes` + * workspace-scoped store and have no `status` field on them; this store + * parallels that layout data with agent-lifecycle state so sidebar icons + * and tab chrome can indicate working/permission/review per pane and per + * workspace. + * + * Separate from the v1 `useTabsStore` because v2 paneIds aren't registered + * there — v2's derivation has to iterate this store directly and filter + * by workspaceId. + */ + +interface PaneStatusEntry { + workspaceId: string; + status: PaneStatus; +} + +interface V2PaneStatusState { + statuses: Record; + setPaneStatus: ( + paneId: string, + workspaceId: string, + status: PaneStatus, + ) => void; + clearPaneStatus: (paneId: string) => void; + clearWorkspaceStatuses: (workspaceId: string) => void; + /** + * Clear post-completion attention statuses (review) for a workspace. + * Mirrors v1's `resetWorkspaceStatus` — called when the user navigates + * into the workspace, since they're now looking at it and don't need + * the sidebar indicator anymore. Leaves `working` and `permission` + * untouched because those are still-active states. + */ + clearWorkspaceAttention: (workspaceId: string) => void; +} + +export const useV2PaneStatusStore = create()((set) => ({ + statuses: {}, + setPaneStatus: (paneId, workspaceId, status) => { + set((state) => ({ + statuses: { + ...state.statuses, + [paneId]: { workspaceId, status }, + }, + })); + }, + clearPaneStatus: (paneId) => { + set((state) => { + if (!state.statuses[paneId]) return state; + const { [paneId]: _removed, ...rest } = state.statuses; + return { statuses: rest }; + }); + }, + clearWorkspaceStatuses: (workspaceId) => { + set((state) => { + const next: Record = {}; + for (const [paneId, entry] of Object.entries(state.statuses)) { + if (entry.workspaceId !== workspaceId) { + next[paneId] = entry; + } + } + return { statuses: next }; + }); + }, + clearWorkspaceAttention: (workspaceId) => { + set((state) => { + const next: Record = {}; + let changed = false; + for (const [paneId, entry] of Object.entries(state.statuses)) { + if (entry.workspaceId === workspaceId && entry.status === "review") { + changed = true; + continue; + } + next[paneId] = entry; + } + return changed ? { statuses: next } : state; + }); + }, +})); + +/** + * Derive the highest-priority active status across all panes in a + * workspace. Returns null when every pane is idle — matches the v1 + * `WorkspaceListItem` derivation shape. + */ +export function selectWorkspaceStatus(workspaceId: string) { + return (state: V2PaneStatusState) => { + function* paneStatuses() { + for (const entry of Object.values(state.statuses)) { + if (entry.workspaceId === workspaceId) { + yield entry.status; + } + } + } + return getHighestPriorityStatus(paneStatuses()); + }; +} diff --git a/bun.lock b/bun.lock index 806d7f24daa..9c318eb014e 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.5.8", + "version": "1.5.9", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index 47c594d92e5..93e227b5823 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -129,11 +129,13 @@ export class EventBus { * `git:changed` pattern. */ broadcastAgentLifecycle( - message: Omit< - Extract, - "type" - >, + message: Omit, "type">, ): void { + console.log("[event-bus] broadcastAgentLifecycle", { + clientCount: this.clients.size, + workspaceId: message.workspaceId, + eventType: message.eventType, + }); this.broadcast({ type: "agent:lifecycle", ...message }); } diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 4553142ad21..3f2718d15ee 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -27,6 +27,7 @@ export interface AgentLifecycleMessage { eventType: AgentLifecycleEventType; paneId?: string; tabId?: string; + terminalId?: string; sessionId?: string; hookSessionId?: string; resourceId?: string; diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index 7168f80e40d..ba63ba41ffc 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -113,9 +113,9 @@ interface BuildV2TerminalEnvParams { agentHookPort: string; agentHookVersion: string; /** tRPC URL for the host-service notifications.hook mutation. */ - hostAgentHookUrl: string; + hostAgentHookUrl?: string; /** PSK the agent attaches as `Authorization: Bearer `. */ - hostAgentHookToken: string; + hostAgentHookToken?: string; } /** @@ -170,6 +170,15 @@ export function buildV2TerminalEnv( if (hostAgentHookUrl && hostAgentHookToken) { env.SUPERSET_HOST_AGENT_HOOK_URL = hostAgentHookUrl; env.SUPERSET_HOST_AGENT_HOOK_TOKEN = hostAgentHookToken; + console.log("[terminal-env] v2 hook vars injected", { + url: hostAgentHookUrl, + tokenLen: hostAgentHookToken.length, + }); + } else { + console.log("[terminal-env] v2 hook vars NOT injected", { + hasUrl: Boolean(hostAgentHookUrl), + hasToken: Boolean(hostAgentHookToken), + }); } if (supersetHomeDir) { diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index 4284778442b..a14f14701b9 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -11,6 +11,7 @@ import { protectedProcedure, router } from "../../index"; const hookInput = z.object({ paneId: z.string().optional(), tabId: z.string().optional(), + terminalId: z.string().optional(), workspaceId: z.string().optional(), sessionId: z.string().optional(), hookSessionId: z.string().optional(), @@ -27,29 +28,43 @@ export const notificationsRouter = router({ * the event type and fan out over the WebSocket event bus so clients * (desktop renderer, web) can play the finish sound themselves. */ - hook: protectedProcedure - .input(hookInput) - .mutation(async ({ ctx, input }) => { - const eventType = mapEventType(input.eventType); - if (!eventType) { - return { success: true, ignored: true as const }; - } + hook: protectedProcedure.input(hookInput).mutation(async ({ ctx, input }) => { + console.log("[notifications.hook] received", { + raw: input.eventType, + workspaceId: input.workspaceId, + paneId: input.paneId, + tabId: input.tabId, + }); + const eventType = mapEventType(input.eventType); + if (!eventType) { + console.log( + "[notifications.hook] ignored — unmapped eventType", + input.eventType, + ); + return { success: true, ignored: true as const }; + } - if (!input.workspaceId) { - return { success: true, ignored: true as const }; - } + if (!input.workspaceId) { + console.log("[notifications.hook] ignored — missing workspaceId"); + return { success: true, ignored: true as const }; + } - ctx.eventBus.broadcastAgentLifecycle({ - workspaceId: input.workspaceId, - eventType, - paneId: input.paneId, - tabId: input.tabId, - sessionId: input.sessionId, - hookSessionId: input.hookSessionId, - resourceId: input.resourceId, - occurredAt: Date.now(), - }); + console.log("[notifications.hook] broadcasting", { + workspaceId: input.workspaceId, + eventType, + }); + ctx.eventBus.broadcastAgentLifecycle({ + workspaceId: input.workspaceId, + eventType, + paneId: input.paneId, + tabId: input.tabId, + terminalId: input.terminalId, + sessionId: input.sessionId, + hookSessionId: input.hookSessionId, + resourceId: input.resourceId, + occurredAt: Date.now(), + }); - return { success: true, ignored: false as const }; - }), + return { success: true, ignored: false as const }; + }), }); diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index a5e9fc0e4d3..e697c3bee6d 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -23,6 +23,7 @@ export interface AgentLifecyclePayload { eventType: AgentLifecycleEventType; paneId?: string; tabId?: string; + terminalId?: string; sessionId?: string; hookSessionId?: string; resourceId?: string; @@ -84,6 +85,14 @@ function handleMessage(state: ConnectionState, data: unknown): void { return; } + if (message.type === "agent:lifecycle") { + console.log("[event-bus-client] agent:lifecycle received", { + workspaceId: message.workspaceId, + eventType: message.eventType, + listenerCount: state.listeners.size, + }); + } + for (const entry of state.listeners) { if (entry.type !== message.type) continue; @@ -117,6 +126,7 @@ function handleMessage(state: ConnectionState, data: unknown): void { eventType: message.eventType, paneId: message.paneId, tabId: message.tabId, + terminalId: message.terminalId, sessionId: message.sessionId, hookSessionId: message.hookSessionId, resourceId: message.resourceId, From 6acb4a3405546ee9cbe5b31b44594a8e78a27aa1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:28:22 -0700 Subject: [PATCH 03/17] chore(desktop): hoist v2 agent-hook listener + drop debug logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount `V2AgentHookListeners` at the authenticated layout level so every open v2 workspace subscribes for agent-lifecycle events. Backgrounded workspaces now light up the sidebar dot and play the finish sound, not just the currently-viewed one. Multiple listeners per host reuse one WebSocket connection, so this is O(1 socket per host). Strips the per-hop console.logs added while debugging the initial pipeline — they served their purpose (empty-string paneId fallback bug in the coalesce chain, missing terminalId forwarding). --- .../useV2AgentHookListener.ts | 54 +++++-------------- .../v2-workspace/$workspaceId/page.tsx | 2 - .../V2AgentHookListeners.tsx | 38 +++++++++++++ .../components/V2AgentHookListeners/index.ts | 1 + .../renderer/routes/_authenticated/layout.tsx | 2 + packages/host-service/src/events/event-bus.ts | 5 -- packages/host-service/src/terminal/env.ts | 9 ---- .../router/notifications/notifications.ts | 15 ------ packages/workspace-client/src/lib/eventBus.ts | 8 --- 9 files changed, 53 insertions(+), 81 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index a9ba1102ef4..252b1f4c749 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -19,6 +19,10 @@ import { isPaneVisible } from "./isPaneVisible"; * Keeps v1 behavior: skip `Start` for sound, suppress when the event's * pane is visible and the window is focused, and honor the existing * mute/volume settings. + * + * Mount once per v2 workspace you want to receive events for. The + * layout-level `V2AgentHookListenersMount` component iterates every open + * workspace so backgrounded workspaces also light up the sidebar. */ export function useV2AgentHookListener(workspaceId: string): void { const { data: volume = 100 } = @@ -28,28 +32,12 @@ export function useV2AgentHookListener(workspaceId: string): void { const handleEvent = useCallback( (payload: AgentLifecyclePayload) => { - console.log("[useV2AgentHookListener] handleEvent", { - workspaceId, - eventType: payload.eventType, - paneId: payload.paneId, - tabId: payload.tabId, - }); updatePaneStatus(workspaceId, payload); if (payload.eventType === "Start") return; - const suppress = shouldSuppress(workspaceId, payload); - console.log("[useV2AgentHookListener] suppress check", { - suppress, - eventType: payload.eventType, - }); - if (suppress) return; + if (shouldSuppress(workspaceId, payload)) return; const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; - console.log("[useV2AgentHookListener] playing ringtone", { - ringtoneId, - volume, - muted, - }); void playRingtone({ ringtoneId, volume, muted }); showNativeNotification(payload, workspaceId); @@ -73,14 +61,12 @@ function updatePaneStatus( workspaceId: string, payload: AgentLifecyclePayload, ): void { - // V2 terminals don't have a `paneId` (those live in the client-side - // panes store); fall back to terminalId / sessionId / hookSessionId as - // the unique key. The sidebar selector only filters on workspaceId so - // any non-empty unique id per running agent is fine — we just need - // SOMETHING to distinguish concurrent agents in the same workspace. - // - // Agent payloads frequently send empty strings (""), not missing - // fields, so `??` is wrong here — use a blank-string coalesce. + // V2 terminals expose `SUPERSET_TERMINAL_ID` but not `SUPERSET_PANE_ID` + // (panes are a client-side layout concept in v2, unknown to host-service), + // and agents frequently send empty strings for missing fields — not + // undefined — so `??` is wrong here. `firstNonBlank` falls through + // empties to the next candidate. The sidebar selector only filters on + // workspaceId, so any non-empty unique id per agent is fine. const paneId = firstNonBlank( payload.paneId, payload.terminalId, @@ -88,25 +74,15 @@ function updatePaneStatus( payload.hookSessionId, payload.resourceId, ); - if (!paneId) { - console.log( - "[useV2AgentHookListener] updatePaneStatus skipped — no identifier", - payload, - ); - return; - } + if (!paneId) return; const store = useV2PaneStatusStore.getState(); if (payload.eventType === "Start") { - console.log("[useV2AgentHookListener] setPaneStatus working", { paneId }); store.setPaneStatus(paneId, workspaceId, "working"); return; } if (payload.eventType === "PermissionRequest") { - console.log("[useV2AgentHookListener] setPaneStatus permission", { - paneId, - }); store.setPaneStatus(paneId, workspaceId, "permission"); return; } @@ -115,12 +91,6 @@ function updatePaneStatus( const prev = store.statuses[paneId]?.status; const viewing = isCurrentWorkspace(workspaceId); const nextStatus = prev === "permission" || viewing ? "idle" : "review"; - console.log("[useV2AgentHookListener] Stop -> transition", { - paneId, - prev, - viewing, - nextStatus, - }); if (nextStatus === "idle") { store.clearPaneStatus(paneId); } else { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index f664242e637..5ac07b269b9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -33,7 +33,6 @@ import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActio import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; -import { useV2AgentHookListener } from "./hooks/useV2AgentHookListener"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -72,7 +71,6 @@ function V2WorkspacePage() { const { terminalId, chatSessionId } = Route.useSearch(); const collections = useCollections(); - useV2AgentHookListener(workspaceId); useClearPaneAttentionOnView(workspaceId); const { data: workspaces } = useLiveQuery( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx new file mode 100644 index 00000000000..db9ed05e084 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx @@ -0,0 +1,38 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useV2AgentHookListener } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** + * Mounts one agent-lifecycle listener per v2 workspace so backgrounded + * workspaces also update their sidebar status indicator and play the + * finish sound. Sibling to `AgentHooks`; rendered at the authenticated + * layout level. + * + * The listener hook calls `useWorkspaceEvent`, which resolves the + * workspace's host URL and subscribes — multiple listeners against the + * same host reuse one WebSocket connection, so this is O(1 socket per + * host), not O(n sockets per workspace). + */ +export function V2AgentHookListeners() { + const collections = useCollections(); + const { data: workspaces = [] } = useLiveQuery( + (q) => + q + .from({ v2Workspaces: collections.v2Workspaces }) + .select(({ v2Workspaces }) => ({ id: v2Workspaces.id })), + [collections], + ); + + return ( + <> + {workspaces.map((w) => ( + + ))} + + ); +} + +function WorkspaceListener({ workspaceId }: { workspaceId: string }): null { + useV2AgentHookListener(workspaceId); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts new file mode 100644 index 00000000000..6f922eb8d6d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts @@ -0,0 +1 @@ +export { V2AgentHookListeners } from "./V2AgentHookListeners"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 0df5235ef77..f69e06389a0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -35,6 +35,7 @@ import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; +import { V2AgentHookListeners } from "./components/V2AgentHookListeners"; import { createPierreWorker } from "./lib/pierreWorker"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { DeletingWorkspacesProvider } from "./providers/DeletingWorkspacesProvider"; @@ -197,6 +198,7 @@ function AuthenticatedLayout() { highlighterOptions={{ preferredHighlighter: "shiki-wasm" }} > + {isV2CloudEnabled ? ( diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index 93e227b5823..2dd6b7aa455 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -131,11 +131,6 @@ export class EventBus { broadcastAgentLifecycle( message: Omit, "type">, ): void { - console.log("[event-bus] broadcastAgentLifecycle", { - clientCount: this.clients.size, - workspaceId: message.workspaceId, - eventType: message.eventType, - }); this.broadcast({ type: "agent:lifecycle", ...message }); } diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index ba63ba41ffc..f38f56abd18 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -170,15 +170,6 @@ export function buildV2TerminalEnv( if (hostAgentHookUrl && hostAgentHookToken) { env.SUPERSET_HOST_AGENT_HOOK_URL = hostAgentHookUrl; env.SUPERSET_HOST_AGENT_HOOK_TOKEN = hostAgentHookToken; - console.log("[terminal-env] v2 hook vars injected", { - url: hostAgentHookUrl, - tokenLen: hostAgentHookToken.length, - }); - } else { - console.log("[terminal-env] v2 hook vars NOT injected", { - hasUrl: Boolean(hostAgentHookUrl), - hasToken: Boolean(hostAgentHookToken), - }); } if (supersetHomeDir) { diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index a14f14701b9..49cfc6815db 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -29,30 +29,15 @@ export const notificationsRouter = router({ * (desktop renderer, web) can play the finish sound themselves. */ hook: protectedProcedure.input(hookInput).mutation(async ({ ctx, input }) => { - console.log("[notifications.hook] received", { - raw: input.eventType, - workspaceId: input.workspaceId, - paneId: input.paneId, - tabId: input.tabId, - }); const eventType = mapEventType(input.eventType); if (!eventType) { - console.log( - "[notifications.hook] ignored — unmapped eventType", - input.eventType, - ); return { success: true, ignored: true as const }; } if (!input.workspaceId) { - console.log("[notifications.hook] ignored — missing workspaceId"); return { success: true, ignored: true as const }; } - console.log("[notifications.hook] broadcasting", { - workspaceId: input.workspaceId, - eventType, - }); ctx.eventBus.broadcastAgentLifecycle({ workspaceId: input.workspaceId, eventType, diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index e697c3bee6d..ae638414323 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -85,14 +85,6 @@ function handleMessage(state: ConnectionState, data: unknown): void { return; } - if (message.type === "agent:lifecycle") { - console.log("[event-bus-client] agent:lifecycle received", { - workspaceId: message.workspaceId, - eventType: message.eventType, - listenerCount: state.listeners.size, - }); - } - for (const entry of state.listeners) { if (entry.type !== message.type) continue; From e6dab1864e3f801bef3af218b617974e1d163568 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 15:26:01 -0700 Subject: [PATCH 04/17] fix(desktop): address PR review on v2 agent-hook sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isCurrentWorkspace now matches v2 routes (`/v2-workspace/...`), not just v1. Without this, Stop events always marked `review` even while the user was viewing the workspace. - shouldSuppress falls back to isCurrentWorkspace + focus when the payload lacks paneId/tabId — v2 terminals only expose terminalId, so the previous early-return made v1-parity suppression dead code. - Native notification `tag` reuses firstNonBlank so v2 events don't collide on `workspaceId:_` and stomp each other. - useClearPaneAttentionOnView now re-runs when a new review status arrives for the viewed workspace (not just on mount), so Stop events arriving while on-page also clear the sidebar dot. - Split WorkspaceListener into its own file per AGENTS.md one-component rule. - Plan doc now references the actual endpoint `/trpc/notifications.hook`. Not addressed in this commit (held for discussion): - HOST_SERVICE_SECRET exposure via SUPERSET_HOST_AGENT_HOOK_TOKEN — needs a hook-scoped token design. - v1 fallback when the v2 POST returns non-2xx — design choice between silent fallback and surfacing v2 failures. - Autoplay priming listener stacking + keyboard-retry path. --- .../useV2AgentHookListener.ts | 26 ++++++++++++++++--- .../v2-workspace/$workspaceId/page.tsx | 14 ++++++++-- .../V2AgentHookListeners.tsx | 7 +---- .../WorkspaceListener/WorkspaceListener.tsx | 15 +++++++++++ .../components/WorkspaceListener/index.ts | 1 + ...60422-v2-notification-hooks-client-side.md | 26 +++++++++---------- 6 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 252b1f4c749..69f1b2bbdb5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -110,8 +110,11 @@ function firstNonBlank( function isCurrentWorkspace(workspaceId: string): boolean { try { - const match = window.location.hash.match(/\/workspace\/([^/?#]+)/); - return match?.[1] === workspaceId; + // Matches both v1 `/workspace/` and v2 `/v2-workspace/` + // routes — the hook runs in a mixed-UI window so either can be + // the active URL while an event arrives. + const match = window.location.hash.match(/\/(?:v2-)?workspace\/([^/?#]+)/); + return match ? decodeURIComponent(match[1] ?? "") === workspaceId : false; } catch { return false; } @@ -121,10 +124,16 @@ function shouldSuppress( workspaceId: string, payload: AgentLifecyclePayload, ): boolean { - if (!payload.paneId || !payload.tabId) return false; if (typeof document !== "undefined" && document.hidden) return false; if (typeof window !== "undefined" && !document.hasFocus()) return false; + // V2 terminal payloads have no paneId/tabId; fall back to "is this + // workspace the one the user is currently viewing". That's the best + // approximation of v1's isPaneVisible rule without pane metadata. + if (!payload.paneId || !payload.tabId) { + return isCurrentWorkspace(workspaceId); + } + const tabsState = useTabsStore.getState(); return isPaneVisible({ currentWorkspaceId: workspaceId, @@ -153,10 +162,19 @@ function showNativeNotification( ? "Your agent needs input" : "Your agent has finished"; + const tagId = + firstNonBlank( + payload.paneId, + payload.terminalId, + payload.sessionId, + payload.hookSessionId, + payload.resourceId, + ) ?? "_"; + try { new Notification(title, { body, - tag: `${workspaceId}:${payload.paneId ?? payload.sessionId ?? "_"}`, + tag: `${workspaceId}:${tagId}`, silent: true, }); } catch { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 5ac07b269b9..006826e61ab 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -110,9 +110,19 @@ function useClearPaneAttentionOnView(workspaceId: string): void { const clearWorkspaceAttention = useV2PaneStatusStore( (s) => s.clearWorkspaceAttention, ); + // Re-run whenever a new review status appears for this workspace — else + // a Stop event arriving while the user is already on the page would + // leave the sidebar dot lit until navigation. + const hasReviewStatus = useV2PaneStatusStore((s) => + Object.values(s.statuses).some( + (entry) => entry.workspaceId === workspaceId && entry.status === "review", + ), + ); useEffect(() => { - clearWorkspaceAttention(workspaceId); - }, [workspaceId, clearWorkspaceAttention]); + if (hasReviewStatus) { + clearWorkspaceAttention(workspaceId); + } + }, [workspaceId, clearWorkspaceAttention, hasReviewStatus]); } function WorkspaceContent({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx index db9ed05e084..741eb1f70ec 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx @@ -1,6 +1,6 @@ import { useLiveQuery } from "@tanstack/react-db"; -import { useV2AgentHookListener } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { WorkspaceListener } from "./components/WorkspaceListener"; /** * Mounts one agent-lifecycle listener per v2 workspace so backgrounded @@ -31,8 +31,3 @@ export function V2AgentHookListeners() { ); } - -function WorkspaceListener({ workspaceId }: { workspaceId: string }): null { - useV2AgentHookListener(workspaceId); - return null; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx new file mode 100644 index 00000000000..281484582f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx @@ -0,0 +1,15 @@ +import { useV2AgentHookListener } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; + +/** + * Invisible helper: subscribes a single workspace to agent-lifecycle + * events. Parent `V2AgentHookListeners` renders one of these per open + * v2 workspace so backgrounded workspaces still receive hook events. + */ +export function WorkspaceListener({ + workspaceId, +}: { + workspaceId: string; +}): null { + useV2AgentHookListener(workspaceId); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts new file mode 100644 index 00000000000..ba5551d5a64 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts @@ -0,0 +1 @@ +export { WorkspaceListener } from "./WorkspaceListener"; diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index dd853d64dab..2124fd0335e 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -19,18 +19,18 @@ Goal: play the agent finish sound on the client (web renderer / electron rendere ## Architecture -``` -agent ──POST /hook/complete──▶ host-service - │ - ├── persist event (optional, for reconnect replay) - └── broadcast via existing WebSocket EventBus - │ - ▼ - web client / electron renderer - │ - ├── decide (focus + visibility + settings) - ├── dedup across tabs (event-id) - └── play ringtone + show Notification +```text +agent ──POST /trpc/notifications.hook──▶ host-service + │ + ├── persist event (optional, for reconnect replay) + └── broadcast via existing WebSocket EventBus + │ + ▼ + web client / electron renderer + │ + ├── decide (focus + visibility + settings) + ├── dedup across tabs (event-id) + └── play ringtone + show Notification ``` Key move: the electron localhost hook server (`apps/desktop/src/main/lib/notifications/server.ts`) is retired for v2. Electron main does no sound work. @@ -39,7 +39,7 @@ Key move: the electron localhost hook server (`apps/desktop/src/main/lib/notific Status: next -- Add `POST /hook/complete` to host-service. Port the event-shape validation and normalization from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (Start / Stop / PermissionRequest) into a shared module — plan to import from `packages/shared` or duplicate per v1-v2 duplication memory. +- Add `notifications.hook` tRPC mutation to host-service (shipped as `POST /trpc/notifications.hook`). Port the event-shape validation and normalization from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (Start / Stop / PermissionRequest) into a shared module — plan to import from `packages/shared` or duplicate per v1-v2 duplication memory. - Add `agent:lifecycle` channel to the existing WebSocket event bus (`packages/host-service/src/events/event-bus.ts`, alongside `git:changed` / `fs:events`). Payload: `{ eventId, workspaceId, paneId, tabId, sessionId, type, occurredAt }`. - Broadcast is scoped by `workspaceId` → only subscribers authorized for that workspace receive the event. - Auth: require the workspace token the agent already uses. The endpoint is reachable over the network, so unlike v1's `127.0.0.1`-bound server it must authenticate. From 87d07968926f25e058a9aee83da9bd7547f05125 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 17:53:57 -0700 Subject: [PATCH 05/17] fix(host-service): drop auth on notifications.hook, stop leaking PSK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifications.hook only broadcasts a chime + sidebar dot — no state change, no data access, no code execution. Previously it reused the global HOST_SERVICE_SECRET as a bearer, which was both (a) exposed to every agent shell's env as SUPERSET_HOST_AGENT_HOOK_TOKEN and (b) redundant, since that same secret is already stored in the user- readable manifest.json alongside HOST_SERVICE_SECRET. - Route is now publicProcedure with a note explaining the rationale. - Removes SUPERSET_HOST_AGENT_HOOK_TOKEN from v2 PTY env entirely; the agent script only needs the URL. - Shell hook drops the Authorization header. If the endpoint ever grows capabilities beyond "fan out a chime", re-introduce auth with a hook-scoped secret — not the global PSK. --- .../templates/notify-hook.template.sh | 9 ++++----- packages/host-service/src/terminal/env.ts | 17 +++++++++-------- packages/host-service/src/terminal/terminal.ts | 1 - .../trpc/router/notifications/notifications.ts | 11 +++++++++-- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 56a15eb73d2..7e352719267 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -77,15 +77,15 @@ json_escape() { } # v2: host-service tRPC endpoint. The renderer subscribes over the event -# bus and plays the ringtone. Preferred when both the URL and the PSK are -# provided by host-service's terminal env. -if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_HOST_AGENT_HOOK_TOKEN" ]; then +# bus and plays the ringtone. Preferred when the URL is provided by +# host-service's terminal env. Endpoint is unauthenticated — it only +# broadcasts chimes, no auth header needed. +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 1 --max-time 2 \ - -H "Authorization: Bearer $SUPERSET_HOST_AGENT_HOOK_TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -o /dev/null -w "%{http_code}" 2>/dev/null) @@ -93,7 +93,6 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_HOST_AGENT_HOOK_TOK else curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 1 --max-time 2 \ - -H "Authorization: Bearer $SUPERSET_HOST_AGENT_HOOK_TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ > /dev/null 2>&1 diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index f38f56abd18..261a7833539 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -112,10 +112,12 @@ interface BuildV2TerminalEnvParams { supersetEnv: "development" | "production"; agentHookPort: string; agentHookVersion: string; - /** tRPC URL for the host-service notifications.hook mutation. */ + /** + * tRPC URL for the host-service notifications.hook mutation. + * Endpoint is unauthenticated by design — it only broadcasts chimes, + * no state change. See the router for rationale. + */ hostAgentHookUrl?: string; - /** PSK the agent attaches as `Authorization: Bearer `. */ - hostAgentHookToken?: string; } /** @@ -140,7 +142,6 @@ export function buildV2TerminalEnv( agentHookPort, agentHookVersion, hostAgentHookUrl, - hostAgentHookToken, } = params; // Defense in depth — baseEnv is pre-stripped at init, but strip again @@ -165,11 +166,11 @@ export function buildV2TerminalEnv( env.SUPERSET_AGENT_HOOK_PORT = agentHookPort; env.SUPERSET_AGENT_HOOK_VERSION = agentHookVersion; // v2 — agent posts to host-service so the renderer can play the sound - // client-side. Set only when both URL and token are available; the - // notify-hook script falls back to the electron endpoint otherwise. - if (hostAgentHookUrl && hostAgentHookToken) { + // client-side. No auth token: the endpoint is unauthenticated by design + // (it only broadcasts chimes). The notify-hook script falls back to + // the electron endpoint when this URL isn't set. + if (hostAgentHookUrl) { env.SUPERSET_HOST_AGENT_HOOK_URL = hostAgentHookUrl; - env.SUPERSET_HOST_AGENT_HOOK_TOKEN = hostAgentHookToken; } if (supersetHomeDir) { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 1fc212f338f..f1f1b532713 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -278,7 +278,6 @@ export function createTerminalSessionInternal({ agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", hostAgentHookUrl: getHostAgentHookUrl(), - hostAgentHookToken: process.env.HOST_SERVICE_SECRET || "", }); let pty: IPty; diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index 49cfc6815db..bd2c0f59b1f 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { mapEventType } from "../../../events"; -import { protectedProcedure, router } from "../../index"; +import { publicProcedure, router } from "../../index"; /** * Input shape matches the v1 `/hook/complete` query-string contract so the @@ -27,8 +27,15 @@ export const notificationsRouter = router({ * session-start / permission-request / task-complete events. We normalize * the event type and fan out over the WebSocket event bus so clients * (desktop renderer, web) can play the finish sound themselves. + * + * Intentionally unauthenticated. The only thing a caller can do is + * cause clients to chime and flash a sidebar indicator — no code + * execution, no data access, no state change. Reusing the host-service + * PSK for this endpoint would leak the credential into every agent + * shell's env for zero practical gain (manifest.authToken already + * exposes it to any user-level process). */ - hook: protectedProcedure.input(hookInput).mutation(async ({ ctx, input }) => { + hook: publicProcedure.input(hookInput).mutation(async ({ ctx, input }) => { const eventType = mapEventType(input.eventType); if (!eventType) { return { success: true, ignored: true as const }; From 103c00a17fcf46b224f243ec0177011ef06c1bac Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 18:16:38 -0700 Subject: [PATCH 06/17] fix(desktop): v1 fallback on v2 non-2xx + safer autoplay priming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shell hook now captures the status code from the v2 POST (regardless of debug mode) and only exits if it was 2xx. Otherwise falls through to the electron v1 endpoint — covers host-service restarts, crashes, transient 5xxs without silently dropping the notification. - primeRingtoneAudioOnFirstGesture is now idempotent: if called twice before any gesture, listeners are only installed once. Audio is marked primed only after silent.play() resolves. On rejection the listeners are re-armed so the next gesture retries — with both pointerdown and keydown preserved (the retry path previously dropped keydown, breaking keyboard-only users). --- .../templates/notify-hook.template.sh | 26 +++++++------- .../src/renderer/lib/ringtones/play.ts | 34 ++++++++++++++----- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 7e352719267..ec737eec895 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -79,25 +79,25 @@ json_escape() { # v2: host-service tRPC endpoint. The renderer subscribes over the event # bus and plays the ringtone. Preferred when the URL is provided by # host-service's terminal env. Endpoint is unauthenticated — it only -# broadcasts chimes, no auth header needed. +# broadcasts chimes, no auth header needed. Always captures the status +# so we can fall back to v1 when host-service is unreachable or the +# mutation returns non-2xx (restarts, crashes, transient errors). if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 1 --max-time 2 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ - --connect-timeout 1 --max-time 2 \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - -o /dev/null -w "%{http_code}" 2>/dev/null) echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 - else - curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ - --connect-timeout 1 --max-time 2 \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - > /dev/null 2>&1 fi - exit 0 + + case "$STATUS_CODE" in + 2*) exit 0 ;; + esac fi # v1 fallback: electron localhost server. Used by v1 terminals and when diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts index c95d5a7f94a..025dde9efd8 100644 --- a/apps/desktop/src/renderer/lib/ringtones/play.ts +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -13,27 +13,43 @@ export interface PlayRingtoneOptions { } let audioPrimed = false; +let audioPrimingListenersInstalled = false; /** * Some browsers block `audio.play()` until the user has interacted with the * page. Wire this up once at app mount so the first pointerdown unlocks * autoplay and subsequent hook events can play without a visible gesture. + * Safe to call repeatedly — listeners are only installed once. */ export function primeRingtoneAudioOnFirstGesture(): void { if (audioPrimed || typeof window === "undefined") return; + if (audioPrimingListenersInstalled) return; + audioPrimingListenersInstalled = true; + + const removeListeners = () => { + window.removeEventListener("pointerdown", prime); + window.removeEventListener("keydown", prime); + }; + const prime = () => { - audioPrimed = true; const silent = new Audio(); silent.muted = true; - silent.play().catch(() => { - // If the gesture somehow still can't unlock audio, we'll retry on - // the next one — listener is re-added below. - audioPrimed = false; - window.addEventListener("pointerdown", prime, { once: true }); - }); - window.removeEventListener("pointerdown", prime); - window.removeEventListener("keydown", prime); + silent + .play() + .then(() => { + audioPrimed = true; + removeListeners(); + }) + .catch(() => { + // Browser refused even with a gesture — wait for the next one. + // Listeners stay active (once:true triggered, so re-attach). + audioPrimingListenersInstalled = false; + window.addEventListener("pointerdown", prime, { once: true }); + window.addEventListener("keydown", prime, { once: true }); + audioPrimingListenersInstalled = true; + }); }; + window.addEventListener("pointerdown", prime, { once: true }); window.addEventListener("keydown", prime, { once: true }); } From 3206979eb67dac3ed94d828ef98b7137ee2e1664 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 19:06:17 -0700 Subject: [PATCH 07/17] docs(plans): rewrite v2 notif hooks doc as shipped retrospective --- ...60422-v2-notification-hooks-client-side.md | 205 ++++++++++-------- 1 file changed, 109 insertions(+), 96 deletions(-) diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index 2124fd0335e..f4485514eeb 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -1,137 +1,150 @@ # V2 Notification Hooks: Client-Side Playback -Goal: play the agent finish sound on the client (web renderer / electron renderer) instead of the electron main process, so notifications work when the host-service is off-machine. Keep v1 feature parity: 11 bundled ringtones + single custom slot per user, volume/mute settings, pane-visibility suppression. +Shipped in PR #3675. First commit `f6aed52f4` (the branch's `save` baseline) through `103c00a17`; the doc itself is `828ca8c21`. + +## Goal + +Play the agent finish sound + surface sidebar status on the **renderer** instead of the electron main process, so v2 notifications work when host-service is off-machine (relay / remote device). Keep v1 feature parity: ringtone playback, volume/mute, pane-visibility suppression, sidebar working/permission/review indicator on the dashboard workspace list. + +## Why we moved off electron main + +V1 plays sound in electron main via `afplay`/`paplay` child processes and shows native notifications from main. That works when main and the agent run on the same machine. V2 workspaces can have their PTYs on a remote host-service reached via relay — electron main no longer sits in the agent's path, so it can't hear the hook. Playback has to happen wherever the user is looking: the renderer. ## Principles -- **One code path** for web and electron renderer. Audio plays via `HTMLAudioElement` in the renderer; electron main no longer plays sound. -- **Host-service is the hook ingress.** The agent's `/hook/complete` endpoint moves off electron onto the host-service. Works whether host-service is on the user's machine or remote. -- **Same UX as v1.** Single ringtone per user, applied to all hook events. No event-specific sounds, no randomized variants, no multi-slot custom library — those are out of scope. -- **Feature-parity before parity-plus.** Ship built-ins first, port custom ringtone second, add nothing else. +- **One code path for web and electron renderer.** Audio plays via `HTMLAudioElement` in the renderer; electron main does no sound work for v2. When the web client comes online, the same hooks/stores carry over. +- **Host-service is the hook ingress.** The agent shell script POSTs to host-service's tRPC, not electron's localhost Express server. +- **Same UX as v1.** Single ringtone per user applied to all hook events. No event-specific sounds, no randomized variants, no multi-slot library. +- **Feature-parity before parity-plus.** Ship built-ins first; custom ringtone and Postgres-synced prefs are follow-ups. ## Non-goals - Event-specific sounds (save / format / chat-received). Not v1 behavior. -- Randomized sound variants (`responseReceived1..4.mp3` style). +- Randomized sound variants (vscode's `responseReceived1..4.mp3` style). - Multi-slot custom ringtone library. Keep v1's single-slot model. - Per-workspace ringtone override. -- Native OS integrations beyond `Notification` + `HTMLAudioElement` (no dock bounce, no tray flash). Add later if asked. +- Native OS integrations beyond `Notification` + `HTMLAudioElement` (no dock bounce, no tray flash). +- Cross-device dedup. If a user has web + desktop open, both chime. Same as email. ## Architecture ```text -agent ──POST /trpc/notifications.hook──▶ host-service - │ - ├── persist event (optional, for reconnect replay) - └── broadcast via existing WebSocket EventBus - │ - ▼ - web client / electron renderer - │ - ├── decide (focus + visibility + settings) - ├── dedup across tabs (event-id) - └── play ringtone + show Notification +agent shell hook (notify.sh) + │ POST /trpc/notifications.hook (unauthenticated, loopback) + ▼ +host-service + ├── mapEventType() normalizes 20+ agent-specific strings to Start / Stop / PermissionRequest + └── EventBus.broadcastAgentLifecycle() + │ + ▼ fan out on the existing WebSocket event bus alongside git:changed / fs:events +renderer (desktop electron; web later) + ├── V2AgentHookListeners at _authenticated/layout.tsx — one listener per open v2 workspace + ├── useV2AgentHookListener(workspaceId) + │ ├── updatePaneStatus → useV2PaneStatusStore (working/permission/review) + │ ├── shouldSuppress → skip ringtone if user is viewing + window focused + │ ├── playRingtone → HTMLAudioElement with the 11 bundled v1 mp3s + │ └── new Notification() → native OS toast (silent, we play audio ourselves) + └── DashboardSidebarWorkspaceIcon renders the status dot (amber spinner / red pulse / static green) ``` -Key move: the electron localhost hook server (`apps/desktop/src/main/lib/notifications/server.ts`) is retired for v2. Electron main does no sound work. - -## Phase 1: Host-service hook ingress - -Status: next - -- Add `notifications.hook` tRPC mutation to host-service (shipped as `POST /trpc/notifications.hook`). Port the event-shape validation and normalization from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (Start / Stop / PermissionRequest) into a shared module — plan to import from `packages/shared` or duplicate per v1-v2 duplication memory. -- Add `agent:lifecycle` channel to the existing WebSocket event bus (`packages/host-service/src/events/event-bus.ts`, alongside `git:changed` / `fs:events`). Payload: `{ eventId, workspaceId, paneId, tabId, sessionId, type, occurredAt }`. -- Broadcast is scoped by `workspaceId` → only subscribers authorized for that workspace receive the event. -- Auth: require the workspace token the agent already uses. The endpoint is reachable over the network, so unlike v1's `127.0.0.1`-bound server it must authenticate. -- Hook protocol version header preserved (v1 uses version 2). - -Exit criteria: -- Agent posting to host-service `/hook/complete` produces a WebSocket broadcast on `agent:lifecycle`. -- Auth rejects unknown tokens; `curl` from another workspace cannot spoof. -- Unit tests cover the map-event-type logic (port from `server.test.ts`). +Electron main's v1 hook server (`apps/desktop/src/main/lib/notifications/server.ts`) stays running for v1 terminals. The shell script prefers the v2 host-service endpoint when `SUPERSET_HOST_AGENT_HOOK_URL` is set; falls back to v1 on missing URL or non-2xx response. -## Phase 2: Web client playback +## What shipped -Status: next +### host-service (hook ingress + broadcast) -- Bundle the 11 v1 ringtones as static assets under `apps/web/public/ringtones/`. Copy the files from `apps/desktop/src/resources/sounds/`. `shared/ringtones.ts` (the registry in `apps/desktop/src/shared/ringtones.ts`) moves to `packages/shared/src/ringtones/` so both desktop and web import the same metadata. -- `apps/web/src/lib/ringtones/play.ts`: - - `primeAudioOnFirstGesture()` — attach a one-shot `pointerdown` listener that plays a silent `HTMLAudioElement` to unlock autoplay. Call once at app mount. - - `playRingtone({ ringtoneId, volume, muted })` — if muted or volume 0, no-op. Otherwise `new Audio("/ringtones/")`, set `volume`, `play()`. Fall back silently if `play()` rejects (autoplay blocked before gesture). -- `apps/web/src/hooks/useAgentHookListener.ts`: - - Subscribe to `agent:lifecycle` via existing WebSocket client. - - Suppression rule (v1 parity, see `apps/desktop/src/main/lib/notifications/notification-manager.ts:115`): if the event's pane is visible *and* the window is focused, do not play. - - Tab dedup: track a small LRU `Set` in `sessionStorage` keyed by time bucket so only one tab plays per event. Upgrade to `BroadcastChannel` leader election if the LRU proves janky. - - On play: call `playRingtone(...)` + `new Notification(...)` in parallel. -- Settings UI: list 11 built-ins, preview-play button per row, volume slider, mute toggle. Reuse the v1 renderer's ringtone picker component if it's portable; otherwise build the web version against the same `ringtones` registry. +- **`packages/host-service/src/events/map-event-type.ts`** — normalizes arbitrary agent event names to `Start | Stop | PermissionRequest | null`. Ported from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (duplicated per the v1/v2 duplication memory — v1 dies with the v1 UI sunset, so no shared extraction). +- **`packages/host-service/src/events/types.ts`** — `AgentLifecycleMessage` added to the `ServerMessage` union. Fields: `workspaceId`, `eventType`, optional `paneId` / `tabId` / `terminalId` / `sessionId` / `hookSessionId` / `resourceId`, `occurredAt`. +- **`packages/host-service/src/events/event-bus.ts`** — `broadcastAgentLifecycle()` public method; fans out to all connected sockets, matching the existing `git:changed` pattern (workspaceId filtering happens client-side). +- **`packages/host-service/src/trpc/router/notifications/`** — `notifications.hook` mutation. **`publicProcedure`**. Input shape mirrors v1's `/hook/complete` query string so the same shell script can speak both. On valid input: call `ctx.eventBus.broadcastAgentLifecycle(...)`. +- **`packages/host-service/src/types.ts`** + **`app.ts`** — `eventBus` added to the tRPC context so the mutation can reach it. +- **`packages/host-service/src/terminal/env.ts`** + **`terminal.ts`** — `buildV2TerminalEnv` now injects `SUPERSET_HOST_AGENT_HOOK_URL` (`http://127.0.0.1:$HOST_SERVICE_PORT/trpc/notifications.hook`) into v2 PTY env. No token — endpoint is unauth (see "Why no auth" below). -Exit criteria: -- Posting a hook event to host-service plays the selected built-in ringtone in an open web tab. -- Muted / volume 0 produces no sound. -- Visible + focused pane suppresses sound (matches v1). -- Two tabs open → sound plays once. +### workspace-client (wire format) -## Phase 3: Prefs in Postgres +- **`packages/workspace-client/src/lib/eventBus.ts`** — extended `EventType` with `"agent:lifecycle"`; added `AgentLifecyclePayload`; handler branch in `handleMessage` that passes the payload through. Multiple listeners against the same host reuse one WebSocket connection (existing pooling). +- **`packages/workspace-client/src/index.ts`** — re-exports `AgentLifecyclePayload`. -Status: next +### renderer (playback + sidebar) -- Add columns to the relevant `userSettings` table in `packages/db/src/schema/`: - - `selected_ringtone_id text` (nullable → means default `arcade`) - - `notification_volume real` default 0.5 - - `notification_sounds_muted boolean` default false -- tRPC router `notifications.settings.{get,update}` in `packages/trpc`, consumed by web and electron renderer. -- Electron v2 renderer: read from the same tRPC path instead of local-db. V1 local-db read stays for v1 UI only (per `project_v1_sunset` memory; v1 dies, don't evolve). +- **`apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/`** — overload for `"agent:lifecycle"` next to the existing `git:changed` / `fs:events` overloads. +- **`apps/desktop/src/renderer/lib/ringtones/urls.ts`** — Vite-bundled URLs for the 11 v1 ringtones via `new URL("../../../resources/sounds/", import.meta.url)`. Emits hashed asset URLs in prod, served from dev server in dev, without copying files into `resources/public/`. +- **`apps/desktop/src/renderer/lib/ringtones/play.ts`** — `playRingtone({ ringtoneId, volume, muted })` (HTMLAudioElement, early-return on mute / 0 volume, silent catch on autoplay-blocked) + `primeRingtoneAudioOnFirstGesture()`. The primer is idempotent (repeated calls don't stack listeners), marks `audioPrimed` only after `silent.play()` resolves, and re-arms **both** pointerdown and keydown on failure — the first iteration dropped keydown on the retry path, which would have broken keyboard-only users. +- **`apps/desktop/src/renderer/stores/v2-pane-status/store.ts`** — `useV2PaneStatusStore` with `Record`. Separate from v1's `useTabsStore` because v2 paneIds aren't registered there. Exposes `setPaneStatus`, `clearPaneStatus`, `clearWorkspaceStatuses`, `clearWorkspaceAttention` (review-only clear, mirrors v1's `resetWorkspaceStatus`). `selectWorkspaceStatus(id)` selector aggregates non-idle statuses by workspaceId via `getHighestPriorityStatus`. +- **`apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/`**: + - `useV2AgentHookListener(workspaceId)` — subscribes via `useWorkspaceEvent("agent:lifecycle", ...)`, calls `updatePaneStatus` unconditionally, then `playRingtone` + `Notification` for non-Start events that pass suppression. + - `updatePaneStatus` — maps Start → `working`, PermissionRequest → `permission`, Stop → `idle` (if the user is viewing this workspace) or `review` otherwise. Uses `firstNonBlank(paneId, terminalId, sessionId, hookSessionId, resourceId)` as the store key. + - `shouldSuppress` — document hidden / window not focused → don't suppress. Full pane info → use `isPaneVisible`. Missing pane info (the v2 common case) → fall back to `isCurrentWorkspace` as the closest approximation. + - `isCurrentWorkspace` — matches both `/workspace/` and `/v2-workspace/` hash routes. + - `showNativeNotification` — `new Notification(title, { body, tag, silent: true })`. The `tag` also uses `firstNonBlank` so v2 events don't collide on `workspaceId:_`. + - `isPaneVisible.ts` — small local copy of main's `isPaneVisible` to avoid crossing the renderer/main boundary for a pure data helper. +- **`apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/`** — `V2AgentHookListeners` queries `collections.v2Workspaces` via `useLiveQuery`, renders one invisible `WorkspaceListener` per workspace. `WorkspaceListener` lives in its own file (one-component-per-file rule). Mounted at `_authenticated/layout.tsx` alongside `AgentHooks` — always active, whether or not the user is on a v2 workspace page, so backgrounded workspaces still flash the sidebar dot. +- **`apps/desktop/src/renderer/routes/_authenticated/layout.tsx`** — renders ``; calls `primeRingtoneAudioOnFirstGesture()` on mount. +- **`apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx`** — `useClearPaneAttentionOnView(workspaceId)` clears review statuses on mount AND whenever a new review arrives while the page is open (subscribes to `Object.values(s.statuses).some(...)` for presence so the effect re-fires on in-place arrivals). -Exit criteria: -- A user's ringtone choice syncs across devices. -- Migration generated via drizzle-kit (follow DB migration rules in AGENTS.md — spin up Neon branch, don't hand-edit migrations). +### dashboard sidebar (status display) -## Phase 4: Custom ringtone (single slot) +- **`DashboardSidebarWorkspaceItem`** — subscribes via `useV2PaneStatusStore(selectWorkspaceStatus(id))`; threads `workspaceStatus` through both expanded and collapsed variants. +- **`DashboardSidebarExpandedWorkspaceRow`** — new `workspaceStatus?: ActivePaneStatus | null` prop, forwarded into the icon. +- **`DashboardSidebarWorkspaceIcon`** — already had the dot overlay and spinner machinery; it was just receiving `null`. Now gets real status → same visual as v1: amber `AsciiSpinner` when `working`, red pulsing dot on `permission`, static green dot on `review`. Same `StatusIndicator` component as v1 so the visual is pixel-identical. -Status: later +### agent shell hook -- Schema: reuse `userSettings.selected_ringtone_id` — value `"custom"` means "use the user's custom upload." A parallel column `custom_ringtone_r2_key text` (nullable) stores the upload location. -- Host-service endpoint `POST /ringtones/custom` — accept a single file (multipart), validate size + extension (v1 rules: ≤20MB, `.mp3`/`.wav`/`.ogg`), stream to R2 at `ringtones/custom/`, upsert `custom_ringtone_r2_key` + display name. -- Host-service endpoint `GET /ringtones/custom/url` — returns a short-lived signed URL. -- Client: `getRingtoneBlob(id)` reads blob from IndexedDB; on miss, fetches signed URL → caches → returns. `playRingtone` uses the blob via `URL.createObjectURL`. -- Settings UI: "Upload custom sound" button + preview + remove. Replaces existing custom on upload (v1 semantics — single slot). +- **`apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh`** — added a v2 branch above the existing v1 fallback. Builds a tRPC single-call JSON body (`{"json": {...}}` with superjson transformer), POSTs to `$SUPERSET_HOST_AGENT_HOOK_URL`, captures HTTP status. Exits only on `2xx`; otherwise falls through to the v1 electron endpoint (covers host-service restarts, crashes, transient 5xxs). Debug mode logs status on both paths. -Exit criteria: -- User uploads a `.mp3` in web, selects it, triggers a hook event → correct sound plays. -- Uploading replaces the previous custom ringtone. -- Custom ringtone selection persists and syncs across devices. +## Key decisions -## Phase 5: v1 → v2 custom ringtone migration +- **No auth on `notifications.hook`.** The endpoint only broadcasts chimes — no code execution, no data access, no state change. Reusing the global `HOST_SERVICE_SECRET` as a bearer was both theater (the same secret already sits in a user-readable `~/.superset/host//manifest.json` alongside its port, so any user-level process can grab it) and a leak vector (PTY env exposure to every agent subprocess). We removed the token from PTY env entirely. If the endpoint ever grows real capabilities, re-introduce auth with a hook-scoped secret — not the global PSK. +- **V2 pane status in a separate store.** V2 panes live in `@superset/panes` (a workspace-scoped layout store with no `status` field). Piggybacking on v1's `useTabsStore` wouldn't work because v2 paneIds aren't registered there. `useV2PaneStatusStore` parallels the layout state and filters by workspaceId for sidebar derivation. +- **`terminalId` as the canonical v2 key.** V2 terminals set `SUPERSET_TERMINAL_ID` but not `SUPERSET_PANE_ID` — panes are a client-only concept in v2. The fallback chain is `paneId → terminalId → sessionId → hookSessionId → resourceId`, treating **empty strings as missing** (agents send `""` not `undefined`, so `??` was wrong — we use a `firstNonBlank` helper). +- **Listener at the layout, not per-page.** Mounted once on `_authenticated/layout.tsx` per v2 workspace via `V2AgentHookListeners`. Matches v1's global `useAgentHookListener`. Alternative (subscribe only for the currently-viewed workspace) was a behavior regression — users expect to hear the chime for workspace A while looking at workspace B. +- **Fallback to v1 on v2 failure.** Initial version `exit 0`-ed unconditionally after the v2 POST. Reviewers flagged that host-service restarts would silently drop notifications. Now captures status and only exits on 2xx — otherwise falls through to v1. -Status: later +## Review feedback addressed -- One-shot on first v2 boot of desktop: if `~/.superset/assets/ringtones/notification-custom.{ext}` exists, POST it to host-service `/ringtones/custom`, then delete the local file. -- Read display name from sibling `notification-custom.json`. -- Migration flag in local-db so this runs exactly once per device. +- **`isCurrentWorkspace` matched `/workspace/` only** → v2 routes are `/v2-workspace/`, so Stop events always hit the `review` branch even when the user was viewing. Fixed to match both. +- **`shouldSuppress` dead for v2** → early-returned `false` when `paneId || tabId` missing, but v2 never populates those. Added workspace-level fallback. +- **Notification `tag` collision** → `paneId ?? sessionId ?? "_"` gave v2 events the same `_` tag, so each new notification replaced the previous. Uses `firstNonBlank` now. +- **`useClearPaneAttentionOnView` only ran on mount** → reviews arriving while the user was already on the page lingered on the sidebar. Now re-runs when a review appears for the viewed workspace. +- **`WorkspaceListener` in the same file as `V2AgentHookListeners`** → split per AGENTS.md one-component rule. +- **Autoplay priming listener stacking + dropped keyboard retry** → guarded with `audioPrimingListenersInstalled` and re-arm both pointer + keyboard on retry. +- **Plan doc drift** → `/hook/complete` → `/trpc/notifications.hook`. +- **`HOST_SERVICE_SECRET` in PTY env** → removed; endpoint is now unauth. -Exit criteria: -- Existing users with a v1 custom ringtone find it preserved in v2 without any manual action. +## What we didn't do -## Phase 6: Retire electron main playback +- **Postgres-synced prefs.** Renderer still reads `notificationVolume` / `notificationSoundsMuted` / `selectedRingtoneId` via electron-trpc from local-db. Fine for desktop-only usage. Migrating to Postgres `userSettings` is a follow-up; ship when the web client needs pref sync across devices. +- **Custom ringtones.** v1 supports a single user-uploaded `.mp3` on local filesystem. V2 treats the `"custom"` id as fallback-to-default for now. To ship: R2 upload + IndexedDB cache + one-shot local→R2 migration. Gate on telemetry — worth checking if anyone actually used the feature before investing in storage infra. +- **Web client subscription.** `apps/web` doesn't connect to host-service's event bus yet. The rendering path is already web-compatible (no electron IPC in `playRingtone`, `useV2AgentHookListener`, or the pane-status store). Same hooks should drop in once apps/web has a host-service connection. +- **Cross-tab dedup** (for the web client). If the web client is ever opened in two tabs, both chime. Plan called for `BroadcastChannel` leader election; skip until it's a real problem. +- **Missed events while disconnected.** WebSocket is lossy on reconnect. Fire-and-forget is acceptable for chimes. If "I missed 3 completions" matters, persist hook events in host-service and replay with a `since` cursor. +- **Retiring v1 electron-main audio.** `apps/desktop/src/main/lib/notifications/server.ts`, `play-sound.ts`, and `custom-ringtones.ts` stay for v1 terminals. Delete when v1 UI sunsets (see `project_v1_sunset`). The shell script's v1 fallback can go at the same time. +- **Native dock/tray integrations.** Electron-specific dock bounce, tray flash, etc. Out of scope — browser `Notification` works on all platforms inside Electron. -Status: later (after web + desktop v2 stable) +## Risks / open questions -- Remove `apps/desktop/src/main/lib/notifications/server.ts` (localhost hook server). -- Remove `apps/desktop/src/main/lib/play-sound.ts` (`afplay`/`paplay` shell-outs). -- Remove `apps/desktop/src/main/lib/custom-ringtones.ts` (local FS). -- Keep v1 UI paths that still use these working until the v1 sunset (per `project_v1_sunset`). If v1 is already off by this point, delete outright. +- **Mobile Safari autoplay policy** (when web client ships). Stricter about unprimed audio than desktop Chrome. If the user's first interaction is returning to a backgrounded tab after a hook fires, the sound may be blocked. The `Notification` toast still fires so it degrades gracefully. +- **Multi-device simultaneous play.** Arguably correct (like email notifications on phone + laptop). No dedup. +- **Relay exposure of the unauth endpoint.** If host-service is exposed via relay, anyone who can reach the relay's proxy for this host can POST fake `notifications.hook` events. Concrete impact: nuisance chimes. No state change, no data. We accept this. -Exit criteria: -- No electron main code plays audio. -- Electron renderer and web renderer share one sound path. +## Sequencing (context, not current) -## Risks and open questions +Shipping order in this PR: +1. Pipeline plumbing (map-event-type, event-bus channel, tRPC mutation, terminal-env URL injection, workspace-client, useWorkspaceEvent overload) — `f6aed52f4` +2. Renderer side (playRingtone, useV2AgentHookListener, sidebar wiring, status store) — `7767b729f` +3. Layout-level listener + debug-log cleanup — `6acb4a340` +4. Review fixes — `e6dab1864` +5. Drop auth + remove PSK from PTY — `87d079689` +6. v1 fallback + safer priming — `103c00a17` -- **Missed events while disconnected.** WebSocket is lossy on reconnect. If "I missed 3 completions" matters, persist hook events in host-service and replay with a `since` cursor. If not, fire-and-forget is fine — propose fire-and-forget, revisit if complaints. -- **Autoplay policy.** Mobile Safari is stricter about unprimed audio than desktop Chrome. If the user's first interaction is returning to a backgrounded tab after a hook fires, the sound may be blocked. The `Notification` toast still fires, which degrades gracefully. -- **Off-machine host-service + desktop.** The electron renderer talks to a remote host-service exactly like a browser does — no new IPC needed. But the hook endpoint is now over the network, so the hook token must not leak (scope it per-workspace, short-lived). -- **Multi-device simultaneous play.** If a user has web + desktop open on the same workspace, both play. Cross-device dedup is out of scope — a notification on two devices is arguably correct behavior, same as email. +Postgres prefs + R2 custom ringtones follow in a separate PR when gated on user need. -## Sequencing +## Commit trail -Phases 1 + 2 + 3 are the minimum to declare "client-side playback working with v1 parity minus custom ringtone." Ship those together. Phase 4 + 5 come after, gated on whether anyone actually used the v1 custom-upload feature (worth checking telemetry before investing in R2). +- `f6aed52f4` — initial v2 notification hook pipeline (map-event-type, event-bus, tRPC, terminal-env, workspace-client, ringtones, useV2AgentHookListener, layout priming, plan doc) +- `7767b729f` — v2 sidebar status store + terminalId payload + empty-string coalesce fix +- `6acb4a340` — hoist listener to authenticated layout + drop debug logs +- `e6dab1864` — review fixes (v2 route regex, v2 suppression, notif tag, component split, attention clear, plan doc) +- `87d079689` — drop auth on `notifications.hook`, remove `HOST_SERVICE_SECRET` from PTY env +- `103c00a17` — v1 fallback on v2 non-2xx + idempotent autoplay priming +- `828ca8c21` — this doc From fbd1a0cda8364600ef0f5a0932d4ac65e4f31d71 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 14:33:36 -0700 Subject: [PATCH 08/17] wip(desktop): partial-merge resolutions for v2 notification hooks Intermediate commit capturing in-progress merge work before resolving remaining conflicts with main. --- .../main/lib/agent-setup/notify-hook.test.ts | 11 + .../templates/notify-hook.template.sh | 2 +- .../useWorkspaceEvent/useWorkspaceEvent.ts | 21 +- .../src/renderer/lib/ringtones/play.ts | 56 +- .../hooks/useV2AgentHookListener/index.ts | 6 +- .../useV2AgentHookListener/isPaneVisible.ts | 32 - .../resolveV2NotificationTarget.test.ts | 147 ++++ .../resolveV2NotificationTarget.ts | 210 ++++++ .../statusTransitions.test.ts | 113 +++ .../statusTransitions.ts | 65 ++ .../useV2AgentHookListener.ts | 277 ++++--- .../v2-workspace/$workspaceId/page.tsx | 80 ++- .../V2AgentHookListeners.tsx | 139 +++- .../components/HostListener/HostListener.tsx | 91 +++ .../components/HostListener/index.ts | 2 + .../WorkspaceListener/WorkspaceListener.tsx | 15 - .../components/WorkspaceListener/index.ts | 1 - packages/host-service/src/app.ts | 1 + packages/host-service/src/events/event-bus.ts | 14 + packages/host-service/src/events/index.ts | 1 + packages/host-service/src/events/types.ts | 11 + .../host-service/src/terminal/terminal.ts | 16 + .../src/trpc/router/terminal/terminal.ts | 1 + .../workspace-creation/workspace-creation.ts | 2 + packages/workspace-client/src/index.ts | 1 + packages/workspace-client/src/lib/eventBus.ts | 32 +- ...60422-v2-notification-hooks-client-side.md | 673 +++++++++++++++--- 27 files changed, 1704 insertions(+), 316 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 29db9b10656..51a5de2626f 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -21,4 +21,15 @@ describe("getNotifyScriptContent", () => { "event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", ); }); + + it("gives the v2 host-service hook enough time to avoid false fallback", () => { + const script = readFileSync( + path.join(import.meta.dir, "templates", "notify-hook.template.sh"), + "utf-8", + ); + + expect(script).toContain( + 'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5', + ); + }); }); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index ec737eec895..0a18e93b91d 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -86,7 +86,7 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ - --connect-timeout 1 --max-time 2 \ + --connect-timeout 2 --max-time 5 \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -o /dev/null -w "%{http_code}" 2>/dev/null) diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts index 99e1c7109eb..376db75fbf9 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -2,6 +2,7 @@ import { type AgentLifecyclePayload, type GitChangedPayload, getEventBus, + type TerminalLifecyclePayload, } from "@superset/workspace-client"; import type { FsWatchEvent } from "@superset/workspace-fs/client"; import { useEffect, useEffectEvent } from "react"; @@ -31,12 +32,19 @@ export function useWorkspaceEvent( enabled?: boolean, ): void; export function useWorkspaceEvent( - type: "git:changed" | "fs:events" | "agent:lifecycle", + type: "terminal:lifecycle", + workspaceId: string, + callback: (payload: TerminalLifecyclePayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "git:changed" | "fs:events" | "agent:lifecycle" | "terminal:lifecycle", workspaceId: string, callback: | ((event: FsWatchEvent) => void) | ((payload: GitChangedPayload) => void) - | ((payload: AgentLifecyclePayload) => void), + | ((payload: AgentLifecyclePayload) => void) + | ((payload: TerminalLifecyclePayload) => void), enabled = true, ): void { const hostUrl = useWorkspaceHostUrl(workspaceId); @@ -69,6 +77,15 @@ export function useWorkspaceEvent( }, ); cleanups.push(removeListener); + } else if (type === "terminal:lifecycle") { + const removeListener = bus.on( + "terminal:lifecycle", + workspaceId, + (_wid, payload) => { + (handler as (payload: TerminalLifecyclePayload) => void)(payload); + }, + ); + cleanups.push(removeListener); } else { const removeListener = bus.on( "git:changed", diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts index 025dde9efd8..883936e16a8 100644 --- a/apps/desktop/src/renderer/lib/ringtones/play.ts +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -14,6 +14,7 @@ export interface PlayRingtoneOptions { let audioPrimed = false; let audioPrimingListenersInstalled = false; +let audioPrimingInFlight = false; /** * Some browsers block `audio.play()` until the user has interacted with the @@ -23,56 +24,59 @@ let audioPrimingListenersInstalled = false; */ export function primeRingtoneAudioOnFirstGesture(): void { if (audioPrimed || typeof window === "undefined") return; - if (audioPrimingListenersInstalled) return; - audioPrimingListenersInstalled = true; + if (audioPrimingListenersInstalled || audioPrimingInFlight) return; const removeListeners = () => { window.removeEventListener("pointerdown", prime); window.removeEventListener("keydown", prime); + audioPrimingListenersInstalled = false; + }; + + const installListeners = () => { + if (audioPrimed || audioPrimingListenersInstalled || audioPrimingInFlight) { + return; + } + window.addEventListener("pointerdown", prime, { once: true }); + window.addEventListener("keydown", prime, { once: true }); + audioPrimingListenersInstalled = true; }; const prime = () => { + if (audioPrimed || audioPrimingInFlight) return; + audioPrimingInFlight = true; + removeListeners(); + const silent = new Audio(); silent.muted = true; silent .play() .then(() => { audioPrimed = true; - removeListeners(); + audioPrimingInFlight = false; }) .catch(() => { // Browser refused even with a gesture — wait for the next one. - // Listeners stay active (once:true triggered, so re-attach). - audioPrimingListenersInstalled = false; - window.addEventListener("pointerdown", prime, { once: true }); - window.addEventListener("keydown", prime, { once: true }); - audioPrimingListenersInstalled = true; + audioPrimingInFlight = false; + installListeners(); }); }; - window.addEventListener("pointerdown", prime, { once: true }); - window.addEventListener("keydown", prime, { once: true }); + installListeners(); } /** - * Resolve the bundled audio URL for a ringtone id. Returns null for the - * custom-ringtone id (handled separately via host-service upload — not - * part of this MVP) and for unknown ids that aren't the default. + * Resolve the bundled audio URL for a ringtone id. Custom uploads are not + * wired into renderer playback yet, so custom and unknown ids fall back to the + * default built-in ringtone. */ function resolveRingtoneUrl(ringtoneId: string): string | null { - if (ringtoneId === CUSTOM_RINGTONE_ID) { - // Custom uploads aren't wired into renderer playback yet — fall back - // to the default so muted is the only way to get silence in v2. - return ( - builtInRingtoneUrls[ - getRingtoneById(DEFAULT_RINGTONE_ID)?.filename ?? "" - ] ?? null - ); - } - const ringtone = getRingtoneById(ringtoneId); - if (ringtone && builtInRingtoneUrls[ringtone.filename]) { - return builtInRingtoneUrls[ringtone.filename] ?? null; - } + const ringtone = + ringtoneId === CUSTOM_RINGTONE_ID ? null : getRingtoneById(ringtoneId); + const resolved = ringtone + ? builtInRingtoneUrls[ringtone.filename] + : undefined; + if (resolved) return resolved; + const fallback = getRingtoneById(DEFAULT_RINGTONE_ID); return fallback ? (builtInRingtoneUrls[fallback.filename] ?? null) : null; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts index 06c3016a5bc..dd254327853 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts @@ -1 +1,5 @@ -export { useV2AgentHookListener } from "./useV2AgentHookListener"; +export { + handleV2AgentLifecycleEvent, + handleV2TerminalLifecycleEvent, + useV2AgentHookListener, +} from "./useV2AgentHookListener"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts deleted file mode 100644 index 5e51ca8d074..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/isPaneVisible.ts +++ /dev/null @@ -1,32 +0,0 @@ -interface TabsState { - activeTabIds?: Record; - focusedPaneIds?: Record; -} - -interface PaneLocation { - workspaceId: string; - tabId: string; - paneId: string; -} - -/** - * Renderer-side mirror of - * apps/desktop/src/main/lib/notifications/utils.ts#isPaneVisible. Kept as a - * tiny local copy rather than pulled from `main/` to avoid crossing the - * renderer/main boundary for a pure data helper. - */ -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; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts new file mode 100644 index 00000000000..45834887c87 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "bun:test"; +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { PaneViewerData } from "../../types"; +import { + getNotificationSourceId, + isV2NotificationTargetVisible, + resolveTerminalTarget, + resolveV2NotificationTarget, +} from "./resolveV2NotificationTarget"; + +const WORKSPACE_ID = "workspace-1"; + +const layout: WorkspaceState = { + version: 1, + activeTabId: "tab-active", + tabs: [ + { + id: "tab-active", + createdAt: 1, + activePaneId: "pane-terminal", + layout: { type: "pane", paneId: "pane-terminal" }, + panes: { + "pane-terminal": { + id: "pane-terminal", + kind: "terminal", + data: { terminalId: "terminal-1" }, + }, + "pane-chat-hidden": { + id: "pane-chat-hidden", + kind: "chat", + data: { sessionId: "chat-1" }, + }, + }, + }, + { + id: "tab-background", + createdAt: 2, + activePaneId: "pane-chat-background", + layout: { type: "pane", paneId: "pane-chat-background" }, + panes: { + "pane-chat-background": { + id: "pane-chat-background", + kind: "chat", + data: { sessionId: "chat-2" }, + }, + }, + }, + ], +}; + +function payload( + overrides: Partial, +): AgentLifecyclePayload { + return { + eventType: "Stop", + occurredAt: 1, + ...overrides, + }; +} + +describe("resolveV2NotificationTarget", () => { + it("uses terminal ids to find the owning v2 pane", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-1" }), + paneLayout: layout, + }); + + expect(target).toMatchObject({ + workspaceId: WORKSPACE_ID, + tabId: "tab-active", + paneId: "pane-terminal", + sourceId: "terminal-1", + terminalId: "terminal-1", + }); + }); + + it("uses chat session ids to find the owning v2 pane", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ resourceId: "chat-2" }), + paneLayout: layout, + }); + + expect(target).toMatchObject({ + workspaceId: WORKSPACE_ID, + tabId: "tab-background", + paneId: "pane-chat-background", + sourceId: "chat-2", + chatSessionId: "chat-2", + }); + }); + + it("falls back to a source-only target when no pane matches", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-missing" }), + paneLayout: layout, + }); + + expect(target).toEqual({ + workspaceId: WORKSPACE_ID, + sourceId: "terminal-missing", + terminalId: "terminal-missing", + }); + }); + + it("only reports visible for the active tab and active pane", () => { + const terminalTarget = resolveTerminalTarget({ + workspaceId: WORKSPACE_ID, + terminalId: "terminal-1", + paneLayout: layout, + }); + const backgroundTarget = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ sessionId: "chat-2" }), + paneLayout: layout, + }); + + expect(terminalTarget).not.toBeNull(); + if (!terminalTarget) return; + + expect( + isV2NotificationTargetVisible({ + currentWorkspaceId: WORKSPACE_ID, + paneLayout: layout, + target: terminalTarget, + }), + ).toBe(true); + expect( + isV2NotificationTargetVisible({ + currentWorkspaceId: WORKSPACE_ID, + paneLayout: layout, + target: backgroundTarget, + }), + ).toBe(false); + }); + + it("prefers stable runtime ids over legacy pane ids for status keys", () => { + expect( + getNotificationSourceId( + payload({ paneId: "legacy-pane", terminalId: "terminal-1" }), + ), + ).toBe("terminal-1"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts new file mode 100644 index 00000000000..db4025e2f0a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts @@ -0,0 +1,210 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export interface V2NotificationTarget { + workspaceId: string; + tabId?: string; + paneId?: string; + sourceId: string | null; + terminalId?: string; + chatSessionId?: string; +} + +export function firstNonBlank( + ...values: (string | undefined | null)[] +): string | null { + for (const value of values) { + if (value && value.trim().length > 0) return value; + } + return null; +} + +export function getNotificationSourceId( + payload: Pick< + AgentLifecyclePayload, + "paneId" | "terminalId" | "sessionId" | "hookSessionId" | "resourceId" + >, +): string | null { + return firstNonBlank( + payload.terminalId, + payload.sessionId, + payload.hookSessionId, + payload.resourceId, + payload.paneId, + ); +} + +export function getNotificationSourceIds( + payload: Pick< + AgentLifecyclePayload, + "paneId" | "terminalId" | "sessionId" | "hookSessionId" | "resourceId" + >, +): string[] { + const ids = new Set(); + for (const value of [ + payload.terminalId, + payload.sessionId, + payload.hookSessionId, + payload.resourceId, + payload.paneId, + ]) { + const id = firstNonBlank(value); + if (id) ids.add(id); + } + return [...ids]; +} + +export function resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; +}): V2NotificationTarget { + const sourceId = getNotificationSourceId(payload); + const tabId = firstNonBlank(payload.tabId); + const paneId = firstNonBlank(payload.paneId); + const terminalId = firstNonBlank(payload.terminalId); + if (tabId && paneId) { + return { + workspaceId, + tabId, + paneId, + sourceId, + terminalId: terminalId ?? undefined, + chatSessionId: getChatSessionId(payload) ?? undefined, + }; + } + + const terminalTarget = terminalId + ? resolveTerminalTarget({ + workspaceId, + terminalId, + paneLayout, + sourceId, + }) + : null; + if (terminalTarget) return terminalTarget; + + const chatSessionId = getChatSessionId(payload); + if (chatSessionId) { + const chatTarget = resolveChatTarget({ + workspaceId, + chatSessionId, + paneLayout, + sourceId, + }); + if (chatTarget) return chatTarget; + } + + return { + workspaceId, + sourceId, + terminalId: terminalId ?? undefined, + chatSessionId: chatSessionId ?? undefined, + }; +} + +export function resolveTerminalTarget({ + workspaceId, + terminalId, + paneLayout, + sourceId = terminalId, +}: { + workspaceId: string; + terminalId: string; + paneLayout: WorkspaceState | null | undefined; + sourceId?: string | null; +}): V2NotificationTarget | null { + if (!paneLayout?.tabs) return null; + + for (const tab of paneLayout.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const data = pane.data as Partial; + if (data.terminalId !== terminalId) continue; + return { + workspaceId, + tabId: tab.id, + paneId: pane.id, + sourceId, + terminalId, + }; + } + } + + return null; +} + +export function isV2NotificationTargetVisible({ + currentWorkspaceId, + paneLayout, + target, +}: { + currentWorkspaceId: string | null; + paneLayout: WorkspaceState | null | undefined; + target: V2NotificationTarget; +}): boolean { + if (!currentWorkspaceId || currentWorkspaceId !== target.workspaceId) { + return false; + } + if (!target.tabId || !target.paneId || !paneLayout?.tabs) return false; + + const tab = paneLayout.tabs.find( + (candidate) => candidate.id === target.tabId, + ); + return ( + tab?.activePaneId === target.paneId && paneLayout.activeTabId === tab.id + ); +} + +function resolveChatTarget({ + workspaceId, + chatSessionId, + paneLayout, + sourceId, +}: { + workspaceId: string; + chatSessionId: string; + paneLayout: WorkspaceState | null | undefined; + sourceId: string | null; +}): V2NotificationTarget | null { + if (!paneLayout?.tabs) return null; + + for (const tab of paneLayout.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "chat") continue; + const data = pane.data as Partial; + if (data.sessionId !== chatSessionId) continue; + return { + workspaceId, + tabId: tab.id, + paneId: pane.id, + sourceId, + chatSessionId, + }; + } + } + + return null; +} + +function getChatSessionId( + payload: Pick< + AgentLifecyclePayload, + "sessionId" | "hookSessionId" | "resourceId" + >, +): string | null { + return firstNonBlank( + payload.sessionId, + payload.hookSessionId, + payload.resourceId, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts new file mode 100644 index 00000000000..5ec2b7f3252 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { V2NotificationTarget } from "./resolveV2NotificationTarget"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +const WORKSPACE_ID = "workspace-1"; + +const target: V2NotificationTarget = { + workspaceId: WORKSPACE_ID, + tabId: "tab-1", + paneId: "pane-1", + sourceId: "terminal-1", + terminalId: "terminal-1", +}; + +function payload( + overrides: Partial, +): AgentLifecyclePayload { + return { + eventType: "Stop", + occurredAt: 1, + ...overrides, + }; +} + +describe("resolveV2AgentStatusTransition", () => { + it("marks start as working on the stable source id and clears alternates", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Start", + paneId: "legacy-pane", + terminalId: "terminal-1", + }), + target, + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearIds: ["legacy-pane", "pane-1"], + setStatus: { id: "terminal-1", status: "working" }, + }); + }); + + it("clears permission state on stop even when permission was keyed by an alternate id", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Stop", + paneId: "legacy-pane", + terminalId: "terminal-1", + }), + target, + statuses: { + "legacy-pane": { workspaceId: WORKSPACE_ID, status: "permission" }, + }, + targetVisible: false, + }), + ).toEqual({ + clearIds: ["terminal-1", "legacy-pane", "pane-1"], + setStatus: null, + }); + }); + + it("clears stop when the exact target pane is visible", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + target, + statuses: {}, + targetVisible: true, + }), + ).toEqual({ + clearIds: ["terminal-1", "pane-1"], + setStatus: null, + }); + }); + + it("marks background stop as review", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + target, + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearIds: ["pane-1"], + setStatus: { id: "terminal-1", status: "review" }, + }); + }); + + it("ignores permission state from a different workspace", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + target, + statuses: { + "terminal-1": { workspaceId: "workspace-2", status: "permission" }, + }, + targetVisible: false, + }), + ).toEqual({ + clearIds: ["pane-1"], + setStatus: { id: "terminal-1", status: "review" }, + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts new file mode 100644 index 00000000000..bb1b7a41468 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts @@ -0,0 +1,65 @@ +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { PaneStatus } from "shared/tabs-types"; +import type { V2NotificationTarget } from "./resolveV2NotificationTarget"; +import { getNotificationSourceIds } from "./resolveV2NotificationTarget"; + +interface StatusEntry { + workspaceId: string; + status: PaneStatus; +} + +export interface V2AgentStatusTransition { + clearIds: string[]; + setStatus: { id: string; status: PaneStatus } | null; +} + +export function resolveV2AgentStatusTransition({ + workspaceId, + payload, + target, + statuses, + targetVisible, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + target: V2NotificationTarget; + statuses: Record; + targetVisible: boolean; +}): V2AgentStatusTransition { + const statusIds = new Set(getNotificationSourceIds(payload)); + if (target.paneId) statusIds.add(target.paneId); + + const primaryId = target.sourceId ?? [...statusIds][0] ?? null; + if (!primaryId) return { clearIds: [], setStatus: null }; + + statusIds.add(primaryId); + const alternateIds = [...statusIds].filter((id) => id !== primaryId); + + if (payload.eventType === "Start") { + return { + clearIds: alternateIds, + setStatus: { id: primaryId, status: "working" }, + }; + } + + if (payload.eventType === "PermissionRequest") { + return { + clearIds: alternateIds, + setStatus: { id: primaryId, status: "permission" }, + }; + } + + const allIds = [primaryId, ...alternateIds]; + const wasAwaitingPermission = allIds.some((id) => { + const entry = statuses[id]; + return entry?.workspaceId === workspaceId && entry.status === "permission"; + }); + if (wasAwaitingPermission || targetVisible) { + return { clearIds: allIds, setStatus: null }; + } + + return { + clearIds: alternateIds, + setStatus: { id: primaryId, status: "review" }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 69f1b2bbdb5..1e2fb370023 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -1,12 +1,29 @@ -import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import { useCallback } from "react"; +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useRingtoneStore } from "renderer/stores/ringtone"; -import { useTabsStore } from "renderer/stores/tabs"; import { useV2PaneStatusStore } from "renderer/stores/v2-pane-status"; -import { isPaneVisible } from "./isPaneVisible"; +import type { PaneViewerData } from "../../types"; +import { + getNotificationSourceId, + isV2NotificationTargetVisible, + resolveTerminalTarget, + resolveV2NotificationTarget, + type V2NotificationTarget, +} from "./resolveV2NotificationTarget"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +type Navigate = ReturnType; /** * Listens for v2 agent lifecycle events over the host-service WebSocket, @@ -25,27 +42,107 @@ import { isPaneVisible } from "./isPaneVisible"; * workspace so backgrounded workspaces also light up the sidebar. */ export function useV2AgentHookListener(workspaceId: string): void { + const navigate = useNavigate(); + const collections = useCollections(); const { data: volume = 100 } = electronTrpc.settings.getNotificationVolume.useQuery(); const { data: muted = false } = electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + const { data: localWorkspaceRows = [] } = useLiveQuery( + (query) => + query + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .where(({ v2WorkspaceLocalState }) => + eq(v2WorkspaceLocalState.workspaceId, workspaceId), + ), + [collections, workspaceId], + ); + const paneLayout = useMemo( + () => + (localWorkspaceRows[0]?.paneLayout as + | WorkspaceState + | undefined) ?? null, + [localWorkspaceRows], + ); const handleEvent = useCallback( (payload: AgentLifecyclePayload) => { - updatePaneStatus(workspaceId, payload); - - if (payload.eventType === "Start") return; - if (shouldSuppress(workspaceId, payload)) return; - - const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; - void playRingtone({ ringtoneId, volume, muted }); + handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout, + volume, + muted, + navigate, + }); + }, + [workspaceId, paneLayout, volume, muted, navigate], + ); - showNativeNotification(payload, workspaceId); + const handleTerminalLifecycle = useCallback( + (payload: TerminalLifecyclePayload) => { + handleV2TerminalLifecycleEvent({ + workspaceId, + payload, + paneLayout, + }); }, - [workspaceId, volume, muted], + [workspaceId, paneLayout], ); useWorkspaceEvent("agent:lifecycle", workspaceId, handleEvent); + useWorkspaceEvent("terminal:lifecycle", workspaceId, handleTerminalLifecycle); +} + +export function handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout, + volume, + muted, + navigate, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; + volume: number; + muted: boolean; + navigate: Navigate; +}): void { + const target = resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, + }); + updatePaneStatus(workspaceId, payload, target, paneLayout); + + if (payload.eventType === "Start") return; + if (shouldSuppress(target, paneLayout)) return; + + const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; + void playRingtone({ ringtoneId, volume, muted }); + + showNativeNotification(payload, workspaceId, target, () => { + openNotificationTarget(navigate, workspaceId, target); + }); +} + +export function handleV2TerminalLifecycleEvent({ + workspaceId, + payload, + paneLayout, +}: { + workspaceId: string; + payload: TerminalLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; +}): void { + if (payload.eventType !== "exit") return; + const target = resolveTerminalTarget({ + workspaceId, + terminalId: payload.terminalId, + paneLayout, + }); + clearStatusIds(workspaceId, [payload.terminalId, target?.paneId]); } /** @@ -53,105 +150,72 @@ export function useV2AgentHookListener(workspaceId: string): void { * dashboard sidebar icon can pick it up. V2 panes are not tracked in the * v1 `useTabsStore`, so this is its own source of truth. * - * The Stop transition mirrors v1 (useAgentHookListener.ts): clear to idle - * when the user is currently looking at this workspace (they'll see the - * result immediately); otherwise mark review so the sidebar surfaces it. + * The Stop transition mirrors v1 (useAgentHookListener.ts), but uses the v2 + * pane layout instead of workspace-level guessing: clear to idle when the + * exact target pane is visible, otherwise mark review so the sidebar surfaces + * it. */ function updatePaneStatus( workspaceId: string, payload: AgentLifecyclePayload, + target: V2NotificationTarget, + paneLayout: WorkspaceState | null | undefined, ): void { - // V2 terminals expose `SUPERSET_TERMINAL_ID` but not `SUPERSET_PANE_ID` - // (panes are a client-side layout concept in v2, unknown to host-service), - // and agents frequently send empty strings for missing fields — not - // undefined — so `??` is wrong here. `firstNonBlank` falls through - // empties to the next candidate. The sidebar selector only filters on - // workspaceId, so any non-empty unique id per agent is fine. - const paneId = firstNonBlank( - payload.paneId, - payload.terminalId, - payload.sessionId, - payload.hookSessionId, - payload.resourceId, - ); - if (!paneId) return; const store = useV2PaneStatusStore.getState(); + const targetVisible = isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, + }); + const transition = resolveV2AgentStatusTransition({ + workspaceId, + payload, + target, + statuses: store.statuses, + targetVisible, + }); - if (payload.eventType === "Start") { - store.setPaneStatus(paneId, workspaceId, "working"); - return; - } - - if (payload.eventType === "PermissionRequest") { - store.setPaneStatus(paneId, workspaceId, "permission"); - return; - } - - if (payload.eventType === "Stop") { - const prev = store.statuses[paneId]?.status; - const viewing = isCurrentWorkspace(workspaceId); - const nextStatus = prev === "permission" || viewing ? "idle" : "review"; - if (nextStatus === "idle") { - store.clearPaneStatus(paneId); - } else { - store.setPaneStatus(paneId, workspaceId, nextStatus); - } - } -} - -function firstNonBlank( - ...values: (string | undefined | null)[] -): string | null { - for (const v of values) { - if (v && v.length > 0) return v; + clearStatusIds(workspaceId, transition.clearIds); + if (transition.setStatus) { + store.setPaneStatus( + transition.setStatus.id, + workspaceId, + transition.setStatus.status, + ); } - return null; } -function isCurrentWorkspace(workspaceId: string): boolean { +function getCurrentWorkspaceId(): string | null { try { // Matches both v1 `/workspace/` and v2 `/v2-workspace/` // routes — the hook runs in a mixed-UI window so either can be // the active URL while an event arrives. const match = window.location.hash.match(/\/(?:v2-)?workspace\/([^/?#]+)/); - return match ? decodeURIComponent(match[1] ?? "") === workspaceId : false; + return match ? decodeURIComponent(match[1] ?? "") : null; } catch { - return false; + return null; } } function shouldSuppress( - workspaceId: string, - payload: AgentLifecyclePayload, + target: V2NotificationTarget, + paneLayout: WorkspaceState | null | undefined, ): boolean { if (typeof document !== "undefined" && document.hidden) return false; if (typeof window !== "undefined" && !document.hasFocus()) return false; - // V2 terminal payloads have no paneId/tabId; fall back to "is this - // workspace the one the user is currently viewing". That's the best - // approximation of v1's isPaneVisible rule without pane metadata. - if (!payload.paneId || !payload.tabId) { - return isCurrentWorkspace(workspaceId); - } - - const tabsState = useTabsStore.getState(); - return isPaneVisible({ - currentWorkspaceId: workspaceId, - tabsState: { - activeTabIds: tabsState.activeTabIds, - focusedPaneIds: tabsState.focusedPaneIds, - }, - pane: { - workspaceId, - tabId: payload.tabId, - paneId: payload.paneId, - }, + return isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, }); } function showNativeNotification( payload: AgentLifecyclePayload, workspaceId: string, + target: V2NotificationTarget, + onClick: () => void, ): void { if (typeof Notification === "undefined") return; if (Notification.permission !== "granted") return; @@ -162,23 +226,54 @@ function showNativeNotification( ? "Your agent needs input" : "Your agent has finished"; - const tagId = - firstNonBlank( - payload.paneId, - payload.terminalId, - payload.sessionId, - payload.hookSessionId, - payload.resourceId, - ) ?? "_"; + const tagId = target.sourceId ?? getNotificationSourceId(payload) ?? "_"; try { - new Notification(title, { + const notification = new Notification(title, { body, tag: `${workspaceId}:${tagId}`, silent: true, }); + notification.onclick = (event) => { + event.preventDefault(); + onClick(); + notification.close(); + }; } catch { // Notification constructor can throw if the permission was revoked // between the check and the call. Non-fatal. } } + +function clearStatusIds( + workspaceId: string, + ids: Array, +): void { + const store = useV2PaneStatusStore.getState(); + const uniqueIds = new Set(ids.filter((id): id is string => Boolean(id))); + for (const id of uniqueIds) { + if (store.statuses[id]?.workspaceId === workspaceId) { + store.clearPaneStatus(id); + } + } +} + +function openNotificationTarget( + navigate: Navigate, + workspaceId: string, + target: V2NotificationTarget, +): void { + if (typeof window !== "undefined") { + window.focus(); + localStorage.setItem("lastViewedWorkspaceId", workspaceId); + } + + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search: { + terminalId: target.terminalId, + chatSessionId: target.chatSessionId, + }, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 006826e61ab..cdb5dbc506c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,4 +1,9 @@ -import { type PaneActionConfig, Workspace } from "@superset/panes"; +import { + type Pane, + type PaneActionConfig, + Workspace, + type WorkspaceStore, +} from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; import { ResizableHandle, @@ -22,6 +27,7 @@ import { toRelativeWorkspacePath, } from "shared/absolute-paths"; import { useStore } from "zustand"; +import type { StoreApi } from "zustand/vanilla"; import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2PresetsBar } from "./components/V2PresetsBar"; @@ -71,8 +77,6 @@ function V2WorkspacePage() { const { terminalId, chatSessionId } = Route.useSearch(); const collections = useCollections(); - useClearPaneAttentionOnView(workspaceId); - const { data: workspaces } = useLiveQuery( (q) => q @@ -102,27 +106,64 @@ function V2WorkspacePage() { } /** - * Clear "review" statuses for this workspace whenever the user is viewing - * the workspace page. Mirrors v1's `resetWorkspaceStatus` effect: being - * on the page counts as attention, so the sidebar indicator should clear. + * Clear post-completion attention only for the pane the user is actually + * viewing. Clearing every review status on route entry would drop background + * tab attention before the user has looked at that pane. */ -function useClearPaneAttentionOnView(workspaceId: string): void { - const clearWorkspaceAttention = useV2PaneStatusStore( - (s) => s.clearWorkspaceAttention, +function useClearActivePaneAttention({ + workspaceId, + store, +}: { + workspaceId: string; + store: StoreApi>; +}): void { + const activePaneKeys = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + const pane = tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; + return getPaneAttentionKeys(pane).join("\u0000"); + }); + const clearPaneStatus = useV2PaneStatusStore( + (state) => state.clearPaneStatus, ); - // Re-run whenever a new review status appears for this workspace — else - // a Stop event arriving while the user is already on the page would - // leave the sidebar dot lit until navigation. - const hasReviewStatus = useV2PaneStatusStore((s) => - Object.values(s.statuses).some( - (entry) => entry.workspaceId === workspaceId && entry.status === "review", - ), + const hasActivePaneReview = useV2PaneStatusStore((state) => + activePaneKeys + .split("\u0000") + .filter(Boolean) + .some( + (key) => + state.statuses[key]?.workspaceId === workspaceId && + state.statuses[key]?.status === "review", + ), ); + useEffect(() => { - if (hasReviewStatus) { - clearWorkspaceAttention(workspaceId); + if (!hasActivePaneReview) return; + for (const key of activePaneKeys.split("\u0000").filter(Boolean)) { + const entry = useV2PaneStatusStore.getState().statuses[key]; + if (entry?.workspaceId === workspaceId && entry.status === "review") { + clearPaneStatus(key); + } } - }, [workspaceId, clearWorkspaceAttention, hasReviewStatus]); + }, [activePaneKeys, clearPaneStatus, hasActivePaneReview, workspaceId]); +} + +function getPaneAttentionKeys( + pane: Pane | undefined, +): string[] { + if (!pane) return []; + + const keys = new Set([pane.id]); + if (pane.kind === "terminal") { + const data = pane.data as TerminalPaneData; + if (data.terminalId) keys.add(data.terminalId); + } + if (pane.kind === "chat") { + const data = pane.data as ChatPaneData; + if (data.sessionId) keys.add(data.sessionId); + } + return [...keys]; } function WorkspaceContent({ @@ -147,6 +188,7 @@ function WorkspaceContent({ projectId, workspaceId, }); + useClearActivePaneAttention({ workspaceId, store }); const { matchedPresets, executePreset } = useV2PresetExecution({ store, workspaceId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx index 741eb1f70ec..67d23bcabab 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx @@ -1,33 +1,146 @@ +import type { WorkspaceState } from "@superset/panes"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { WorkspaceListener } from "./components/WorkspaceListener"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + HostListener, + type HostWorkspaceListenerState, +} from "./components/HostListener"; + +interface WorkspaceHostRow { + workspaceId: string; + hostId: string; + hostMachineId: string | null | undefined; +} + +interface HostListenerGroup { + hostUrl: string; + workspaces: HostWorkspaceListenerState[]; +} /** - * Mounts one agent-lifecycle listener per v2 workspace so backgrounded - * workspaces also update their sidebar status indicator and play the - * finish sound. Sibling to `AgentHooks`; rendered at the authenticated - * layout level. + * Mounts one v2 notification listener per host-service URL so backgrounded + * workspaces update their sidebar status indicator and play the finish sound. + * Sibling to `AgentHooks`; rendered at the authenticated layout level. * - * The listener hook calls `useWorkspaceEvent`, which resolves the - * workspace's host URL and subscribes — multiple listeners against the - * same host reuse one WebSocket connection, so this is O(1 socket per - * host), not O(n sockets per workspace). + * A host listener subscribes with workspaceId `*` and filters against the + * workspaces assigned to that host. This keeps the topology O(1 listener per + * host), not O(1 listener and settings observer per workspace). */ export function V2AgentHookListeners() { const collections = useCollections(); - const { data: workspaces = [] } = useLiveQuery( + const { machineId, activeHostUrl } = useLocalHostService(); + const { data: workspaceHosts = [] } = useLiveQuery( (q) => q .from({ v2Workspaces: collections.v2Workspaces }) - .select(({ v2Workspaces }) => ({ id: v2Workspaces.id })), + .leftJoin( + { v2Hosts: collections.v2Hosts }, + ({ v2Workspaces, v2Hosts }) => eq(v2Workspaces.hostId, v2Hosts.id), + ) + .select(({ v2Workspaces, v2Hosts }) => ({ + workspaceId: v2Workspaces.id, + hostId: v2Workspaces.hostId, + hostMachineId: v2Hosts?.machineId ?? null, + })), [collections], ); + const { data: localWorkspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .select(({ v2WorkspaceLocalState }) => ({ + workspaceId: v2WorkspaceLocalState.workspaceId, + paneLayout: v2WorkspaceLocalState.paneLayout, + })), + [collections], + ); + const hostGroups = useMemo( + () => + groupWorkspacesByHostUrl({ + workspaceHosts, + localWorkspaceRows, + machineId, + activeHostUrl, + }), + [workspaceHosts, localWorkspaceRows, machineId, activeHostUrl], + ); return ( <> - {workspaces.map((w) => ( - + {hostGroups.map((group) => ( + ))} ); } + +function groupWorkspacesByHostUrl({ + workspaceHosts, + localWorkspaceRows, + machineId, + activeHostUrl, +}: { + workspaceHosts: WorkspaceHostRow[]; + localWorkspaceRows: Array<{ + workspaceId: string; + paneLayout: unknown; + }>; + machineId: string | null; + activeHostUrl: string | null; +}): HostListenerGroup[] { + const paneLayoutsByWorkspaceId = new Map( + localWorkspaceRows.map((row) => [ + row.workspaceId, + row.paneLayout as WorkspaceState, + ]), + ); + const groups = new Map(); + + for (const workspace of workspaceHosts) { + const hostUrl = getHostUrlForWorkspace({ + hostId: workspace.hostId, + hostMachineId: workspace.hostMachineId, + machineId, + activeHostUrl, + }); + if (!hostUrl) continue; + + const group = groups.get(hostUrl) ?? []; + group.push({ + workspaceId: workspace.workspaceId, + paneLayout: paneLayoutsByWorkspaceId.get(workspace.workspaceId) ?? null, + }); + groups.set(hostUrl, group); + } + + return [...groups.entries()].map(([hostUrl, workspaces]) => ({ + hostUrl, + workspaces, + })); +} + +function getHostUrlForWorkspace({ + hostId, + hostMachineId, + machineId, + activeHostUrl, +}: { + hostId: string; + hostMachineId: string | null | undefined; + machineId: string | null; + activeHostUrl: string | null; +}): string | null { + if (hostMachineId && machineId && hostMachineId === machineId) { + return activeHostUrl; + } + return `${env.RELAY_URL}/hosts/${hostId}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx new file mode 100644 index 00000000000..b1d2036d15a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx @@ -0,0 +1,91 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import { getEventBus } from "@superset/workspace-client"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useEffectEvent, useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { + handleV2AgentLifecycleEvent, + handleV2TerminalLifecycleEvent, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +export interface HostWorkspaceListenerState { + workspaceId: string; + paneLayout: WorkspaceState | null; +} + +export function HostListener({ + hostUrl, + workspaces, +}: { + hostUrl: string; + workspaces: HostWorkspaceListenerState[]; +}): null { + const navigate = useNavigate(); + const { data: volume = 100 } = + electronTrpc.settings.getNotificationVolume.useQuery(); + const { data: muted = false } = + electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + const workspacesById = useMemo( + () => + new Map( + workspaces.map((workspace) => [workspace.workspaceId, workspace]), + ), + [workspaces], + ); + + const handleAgentLifecycle = useEffectEvent( + (workspaceId: string, payload: AgentLifecyclePayload) => { + const workspace = workspacesById.get(workspaceId); + if (!workspace) return; + handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout: workspace.paneLayout, + volume, + muted, + navigate, + }); + }, + ); + + const handleTerminalLifecycle = useEffectEvent( + (workspaceId: string, payload: TerminalLifecyclePayload) => { + const workspace = workspacesById.get(workspaceId); + if (!workspace) return; + handleV2TerminalLifecycleEvent({ + workspaceId, + payload, + paneLayout: workspace.paneLayout, + }); + }, + ); + + useEffect(() => { + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + const removeAgentListener = bus.on( + "agent:lifecycle", + "*", + handleAgentLifecycle, + ); + const removeTerminalListener = bus.on( + "terminal:lifecycle", + "*", + handleTerminalLifecycle, + ); + const release = bus.retain(); + + return () => { + removeAgentListener(); + removeTerminalListener(); + release(); + }; + }, [hostUrl]); + + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts new file mode 100644 index 00000000000..1ec4b626704 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts @@ -0,0 +1,2 @@ +export type { HostWorkspaceListenerState } from "./HostListener"; +export { HostListener } from "./HostListener"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx deleted file mode 100644 index 281484582f8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/WorkspaceListener.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useV2AgentHookListener } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; - -/** - * Invisible helper: subscribes a single workspace to agent-lifecycle - * events. Parent `V2AgentHookListeners` renders one of these per open - * v2 workspace so backgrounded workspaces still receive hook events. - */ -export function WorkspaceListener({ - workspaceId, -}: { - workspaceId: string; -}): null { - useV2AgentHookListener(workspaceId); - return null; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts deleted file mode 100644 index ba5551d5a64..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/WorkspaceListener/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceListener } from "./WorkspaceListener"; diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 1e79ed5d6e3..da0d1840770 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -103,6 +103,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { registerWorkspaceTerminalRoute({ app, db, + eventBus, upgradeWebSocket, }); diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index 2dd6b7aa455..6dc3baff6e9 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -134,6 +134,20 @@ export class EventBus { this.broadcast({ type: "agent:lifecycle", ...message }); } + /** + * Fan out terminal process lifecycle events to renderer clients. Agent hook + * status can otherwise get stuck when a terminal exits while its pane is not + * mounted and therefore cannot observe the terminal websocket `exit` packet. + */ + broadcastTerminalLifecycle( + message: Omit< + Extract, + "type" + >, + ): void { + this.broadcast({ type: "terminal:lifecycle", ...message }); + } + private startFsWatch( socket: WsSocket, state: ClientState, diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts index a05a77f04b8..983a1d8a2a2 100644 --- a/packages/host-service/src/events/index.ts +++ b/packages/host-service/src/events/index.ts @@ -12,4 +12,5 @@ export type { FsWatchCommand, GitChangedMessage, ServerMessage, + TerminalLifecycleMessage, } from "./types"; diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 3f2718d15ee..5de8de37279 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -34,6 +34,16 @@ export interface AgentLifecycleMessage { occurredAt: number; } +export interface TerminalLifecycleMessage { + type: "terminal:lifecycle"; + workspaceId: string; + terminalId: string; + eventType: "exit"; + exitCode: number; + signal: number; + occurredAt: number; +} + export interface EventBusErrorMessage { type: "error"; message: string; @@ -43,6 +53,7 @@ export type ServerMessage = | FsEventsMessage | GitChangedMessage | AgentLifecycleMessage + | TerminalLifecycleMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index f1f1b532713..4677e3a2fd2 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -11,6 +11,7 @@ import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; import { projects, terminalSessions, workspaces } from "../db/schema"; +import type { EventBus } from "../events"; import { buildV2TerminalEnv, getShellLaunchArgs, @@ -21,6 +22,7 @@ import { interface RegisterWorkspaceTerminalRouteOptions { app: Hono; db: HostDb; + eventBus: EventBus; upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; } @@ -222,6 +224,7 @@ interface CreateTerminalSessionOptions { workspaceId: string; themeType?: "dark" | "light"; db: HostDb; + eventBus?: EventBus; /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ initialCommand?: string; } @@ -231,6 +234,7 @@ export function createTerminalSessionInternal({ workspaceId, themeType, db, + eventBus, initialCommand, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); @@ -380,6 +384,15 @@ export function createTerminalSessionInternal({ signal: session.exitSignal, }); } + + eventBus?.broadcastTerminalLifecycle({ + workspaceId, + terminalId, + eventType: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + occurredAt: Date.now(), + }); }); if (initialCommand) { @@ -399,6 +412,7 @@ export function createTerminalSessionInternal({ export function registerWorkspaceTerminalRoute({ app, db, + eventBus, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { app.post("/terminal/sessions", async (c) => { @@ -417,6 +431,7 @@ export function registerWorkspaceTerminalRoute({ workspaceId: body.workspaceId, themeType: parseThemeType(body.themeType), db, + eventBus, }); if ("error" in result) { @@ -486,6 +501,7 @@ export function registerWorkspaceTerminalRoute({ workspaceId, themeType, db, + eventBus, }); if ("error" in result) { diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index c90dfd7253b..f1590c32a8b 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -21,6 +21,7 @@ export const terminalRouter = router({ workspaceId: input.workspaceId, themeType: parseThemeType(input.themeType), db: ctx.db, + eventBus: ctx.eventBus, initialCommand: input.initialCommand, }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index b07a2ee693a..db196751279 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -433,6 +433,7 @@ async function finishCheckout( terminalId, workspaceId: cloudRow.id, db: ctx.db, + eventBus: ctx.eventBus, initialCommand: `bash "${setupScriptPath}"`, }); if ("error" in result) { @@ -1010,6 +1011,7 @@ export const workspaceCreationRouter = router({ terminalId, workspaceId: cloudRow.id, db: ctx.db, + eventBus: ctx.eventBus, initialCommand: `bash "${setupScriptPath}"`, }); if ("error" in result) { diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index 3b6cbb0d393..3509a7b8af8 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -5,6 +5,7 @@ export { type EventBusHandle, type GitChangedPayload, getEventBus, + type TerminalLifecyclePayload, } from "./lib/eventBus"; export { useWorkspaceClient, diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index ae638414323..f33e7250905 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -5,7 +5,11 @@ import type { } from "@superset/host-service/events"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; -type EventType = "fs:events" | "git:changed" | "agent:lifecycle"; +type EventType = + | "fs:events" + | "git:changed" + | "agent:lifecycle" + | "terminal:lifecycle"; interface FsEventsPayload { events: FsWatchEvent[]; @@ -30,13 +34,23 @@ export interface AgentLifecyclePayload { occurredAt: number; } +export interface TerminalLifecyclePayload { + eventType: "exit"; + terminalId: string; + exitCode: number; + signal: number; + occurredAt: number; +} + type EventListener = T extends "fs:events" ? (workspaceId: string, payload: FsEventsPayload) => void : T extends "git:changed" ? (workspaceId: string, payload: GitChangedPayload) => void : T extends "agent:lifecycle" ? (workspaceId: string, payload: AgentLifecyclePayload) => void - : never; + : T extends "terminal:lifecycle" + ? (workspaceId: string, payload: TerminalLifecyclePayload) => void + : never; interface ListenerEntry { type: EventType; @@ -91,7 +105,8 @@ function handleMessage(state: ConnectionState, data: unknown): void { const workspaceId = message.type === "fs:events" || message.type === "git:changed" || - message.type === "agent:lifecycle" + message.type === "agent:lifecycle" || + message.type === "terminal:lifecycle" ? message.workspaceId : null; @@ -125,6 +140,17 @@ function handleMessage(state: ConnectionState, data: unknown): void { occurredAt: message.occurredAt, }, ); + } else if (message.type === "terminal:lifecycle") { + (entry.callback as EventListener<"terminal:lifecycle">)( + message.workspaceId, + { + eventType: message.eventType, + terminalId: message.terminalId, + exitCode: message.exitCode, + signal: message.signal, + occurredAt: message.occurredAt, + }, + ); } } } diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index f4485514eeb..bfe1d2d155b 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -1,150 +1,599 @@ -# V2 Notification Hooks: Client-Side Playback +# V2 Notification Hooks: Client-Side Playback Design -Shipped in PR #3675. First commit `f6aed52f4` (the branch's `save` baseline) through `103c00a17`; the doc itself is `828ca8c21`. +Status: PR #3675 shipped an MVP. This document is the design record for where the notification system should go next, including what v1 got wrong, what v2 fixed, what v2 still misses, and the target architecture. -## Goal +## Executive Summary -Play the agent finish sound + surface sidebar status on the **renderer** instead of the electron main process, so v2 notifications work when host-service is off-machine (relay / remote device). Keep v1 feature parity: ringtone playback, volume/mute, pane-visibility suppression, sidebar working/permission/review indicator on the dashboard workspace list. +Agent notification UX should be owned by the client, not by Electron main and not by the host-service. -## Why we moved off electron main +The host-service should only ingest normalized agent lifecycle events and broadcast them over its authenticated event bus. The renderer or web client should resolve those events to visible workspaces/panes, update sidebar attention state, decide whether to suppress the notification, play audio, show OS/browser notifications, and handle click-to-focus. -V1 plays sound in electron main via `afplay`/`paplay` child processes and shows native notifications from main. That works when main and the agent run on the same machine. V2 workspaces can have their PTYs on a remote host-service reached via relay — electron main no longer sits in the agent's path, so it can't hear the hook. Playback has to happen wherever the user is looking: the renderer. +This is the right split because the client is the only layer that knows: -## Principles +- which workspace, tab, and pane the user is currently viewing +- whether the window/tab has focus +- which notification preferences apply +- how to focus the correct UI surface when a notification is clicked +- whether this is desktop, web, or another client -- **One code path for web and electron renderer.** Audio plays via `HTMLAudioElement` in the renderer; electron main does no sound work for v2. When the web client comes online, the same hooks/stores carry over. -- **Host-service is the hook ingress.** The agent shell script POSTs to host-service's tRPC, not electron's localhost Express server. -- **Same UX as v1.** Single ringtone per user applied to all hook events. No event-specific sounds, no randomized variants, no multi-slot library. -- **Feature-parity before parity-plus.** Ship built-ins first; custom ringtone and Postgres-synced prefs are follow-ups. +The shipped v2 path moved playback out of Electron main, which is the important architectural correction. It should now be tightened into a small client-side notification controller with explicit identity resolution, terminal-exit cleanup, click handling, and tests. -## Non-goals +## Goals -- Event-specific sounds (save / format / chat-received). Not v1 behavior. -- Randomized sound variants (vscode's `responseReceived1..4.mp3` style). -- Multi-slot custom ringtone library. Keep v1's single-slot model. -- Per-workspace ringtone override. -- Native OS integrations beyond `Notification` + `HTMLAudioElement` (no dock bounce, no tray flash). -- Cross-device dedup. If a user has web + desktop open, both chime. Same as email. +- Support notifications when the host-service is local, remote, relayed, or eventually cloud-hosted. +- Preserve the good parts of v1 UX: + - sound on completion and permission/input requests + - no sound on start events + - mute, volume, selected ringtone, and eventually custom ringtone support + - suppress notifications when the user is already looking at the relevant pane + - sidebar indicators for working, permission, and review states + - click a notification to focus the relevant workspace/pane + - clear stuck transient statuses when the underlying terminal/session exits +- Keep host-service credentials out of PTY environments. +- Keep the hook endpoint deliberately low-capability. +- Make the shared path usable by desktop and web. +- Make event identity and status transitions testable as pure functions. -## Architecture +## Non-Goals + +- Retire the v1 terminal hook server immediately. V1 terminals still need it until the v1 workspace UI is removed. +- Persist notification events durably. Chimes are acceptable to lose across disconnect/reconnect. +- Add cross-device or cross-tab dedup before web support needs it. +- Add arbitrary agent-provided notification title/body. The client should own displayed copy so a hook cannot spoof system messages. + +## What V1 Got Right + +V1 was not all bad. The target design should keep these behaviors: + +- Hook failures never block the agent. Unknown event types are ignored with a successful response. +- `Start`, `Stop`, and `PermissionRequest` are normalized from several agent-specific hook names. +- `Start` updates working state but does not play a completion sound. +- Notifications are suppressed when the target pane is visible and the window is focused. +- Native notification clicks focus the app and route the renderer to the target tab/pane. +- Terminal exit events clear `working` and `permission` states so interrupted agents do not leave permanent sidebar dots. +- Notification audio honors mute, volume, selected ringtone, and custom ringtone fallback. + +## What V1 Got Wrong + +V1's core problem was ownership. It split one user-facing feature across Electron main, a localhost Express hook server, renderer stores, local DB settings, and a tRPC subscription. + +Specific problems: + +- Electron main owned sound playback and OS notifications. That cannot work for an off-machine host-service or web client. +- Renderer owned pane status, so main had to receive a renderer state snapshot to decide suppression and notification titles. That snapshot can lag and is not a durable contract. +- The hook server was desktop-local and bound to Electron lifecycle. Remote host-service events had no way to reach the user-facing client. +- Notification ingress shared a server with unrelated auth callback fallback behavior. +- Event type mapping was duplicated and not exported from a shared contract. +- The hook protocol relied on query strings and shell-side string scraping. +- Pane identity was weak. Main tried to resolve `paneId` from partial metadata, but v2 panes are client-only and host-service cannot know them. +- Notification state was stored directly on v1 panes, making it hard to share with v2 panes or other clients. +- There was no single testable "notification controller" responsible for status transitions, suppression, playback, and click behavior. + +## What Shipped In PR #3675 + +The MVP moved the playback trigger from Electron main to the renderer for v2 terminals: + +```text +agent shell hook + POST /trpc/notifications.hook + host-service maps event type + host-service broadcasts agent:lifecycle over /events WebSocket + renderer listener updates v2 pane-status store + renderer suppresses or plays ringtone + renderer shows browser/OS Notification + dashboard sidebar reads aggregated v2 status +``` + +Important shipped pieces: + +- `packages/host-service/src/trpc/router/notifications/notifications.ts` + - public `notifications.hook` mutation + - event type normalization + - event-bus broadcast +- `packages/host-service/src/events/*` + - `AgentLifecycleMessage` + - `broadcastAgentLifecycle` +- `packages/workspace-client/src/lib/eventBus.ts` + - typed `agent:lifecycle` client event +- `apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh` + - posts to `SUPERSET_HOST_AGENT_HOOK_URL` + - falls back to the v1 hook server on missing URL or non-2xx response +- `apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners` + - mounts listeners for v2 workspaces +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener` + - updates status, suppresses, plays sound, and shows notifications +- `apps/desktop/src/renderer/stores/v2-pane-status` + - separate v2 status store, aggregated by workspace for the dashboard sidebar +- `apps/desktop/src/renderer/lib/ringtones` + - renderer-side built-in ringtone playback + +This was the right first move, but it should not be the final architecture. + +## Current V2 Gaps + +The current implementation is useful but incomplete. + +- **No v2 terminal-exit cleanup.** V1 clears stuck `working` and `permission` statuses when a terminal exits. V2 status only changes on hook events, so interrupted or killed agents can leave a stale sidebar indicator. +- **No notification click routing.** V1 notification clicks focus the app and route to the target workspace/tab/pane. V2 creates a `Notification` but does not handle clicks. +- **Suppression is too coarse.** If the v2 event lacks `paneId` and `tabId`, suppression falls back to "current workspace is visible." That can suppress a notification for a background pane in the same workspace. The client has v2 pane layout data and should resolve by `terminalId`, `sessionId`, or `resourceId` instead. +- **One listener per workspace is more work than needed.** Event-bus connections are reused per host, but each workspace still mounts a hook and settings queries. A host-level controller should subscribe once per host and fan events into the store. +- **The renderer hook is desktop-specific.** It imports `electronTrpc` for settings, so the current path is not actually web-ready. +- **Browser notification permission is not handled.** The v2 client checks `Notification.permission` but does not request permission or route users to settings. +- **Custom ringtones are not supported.** The v2 path falls back to the default ringtone when `"custom"` is selected. +- **The tests do not cover the new contract.** The copied host-service event mapper, hook mutation, v2 status transitions, suppression, audio fallback, and notification click behavior need direct tests. + +## Target Architecture + +The correct design has five layers, each with a narrow responsibility. + +```text +agent runtime / shell hook + emits raw hook payload and stable Superset identifiers + | + v +host-service notification ingress + validates shape, maps raw agent event names to normalized lifecycle events + performs no user-facing decisions + | + v +host-service event bus + broadcasts normalized events to authenticated clients for that host + | + v +client notification controller + one controller per host connection + resolves event identity to workspace/pane/session + updates attention state + decides suppression + plays sound + shows notification + handles click-to-focus + | + v +UI surfaces + dashboard sidebar, pane chrome, tab chrome, settings UI +``` + +### Layer 1: Agent Hook Script + +The hook script should stay intentionally dumb: + +- read the agent hook payload +- extract known IDs and event type +- POST JSON to `SUPERSET_HOST_AGENT_HOOK_URL` +- time out quickly +- fall back to the v1 hook server only for v1 compatibility +- never receive `HOST_SERVICE_SECRET` or any broad host credential + +The script can continue to be defensive because hooks run in user shells with inconsistent payloads. Long term, wrappers should pass the normalized Superset identifiers directly so the script does less text parsing. + +Required identifiers: + +- `workspaceId`: required for v2 +- one stable source ID: + - `terminalId` for terminal-backed agents + - `sessionId` or `resourceId` for chat-backed agents + - automation run ID when automation notifications move here + +Optional identifiers: + +- `paneId` and `tabId`, when a client-side caller can provide them +- `hookSessionId`, for agent-runtime correlation/debugging + +### Layer 2: Host-Service Notification Ingress + +The host-service endpoint should be an ingest endpoint, not a notification manager. + +Responsibilities: + +- accept the hook payload +- reject oversized or malformed input +- require `workspaceId` +- ignore unknown event types +- normalize raw event names into a small lifecycle vocabulary +- attach `occurredAt` +- broadcast to the event bus +- return success even for ignored events so agent hooks do not block + +It should not: + +- play sound +- create OS notifications +- read user notification settings +- decide whether the user is viewing the target pane +- accept arbitrary notification title/body +- mutate workspace, pane, terminal, or chat state + +Security posture: + +- Keeping this endpoint unauthenticated is acceptable only because it is deliberately low-capability. +- The only allowed effect must remain "broadcast a generic lifecycle event." +- If this endpoint ever gains capabilities beyond chime/sidebar attention, it needs a new auth design. +- Do not reuse `HOST_SERVICE_SECRET` in PTY env. If auth is required later, use a scoped hook token with limited lifetime and limited permissions. +- Add basic abuse controls: + - payload size limit + - event type allowlist + - workspace existence check when cheap + - per-process or per-workspace rate limiting + - generic responses that do not expose workspace data + +### Layer 3: Event Bus Contract + +The event bus should carry normalized lifecycle events and terminal/session lifecycle events. + +Current event: + +```ts +type AgentLifecycleEventType = "Start" | "Stop" | "PermissionRequest"; +``` + +Recommended normalized model: + +```ts +type AgentLifecycleKind = + | "started" + | "waiting-for-input" + | "completed"; + +interface AgentLifecycleEvent { + type: "agent:lifecycle"; + workspaceId: string; + kind: AgentLifecycleKind; + source: { + kind: "terminal" | "chat" | "automation" | "unknown"; + terminalId?: string; + sessionId?: string; + hookSessionId?: string; + resourceId?: string; + automationRunId?: string; + }; + pane?: { + paneId?: string; + tabId?: string; + }; + rawEventType?: string; + occurredAt: number; +} +``` + +The existing `Start` / `Stop` / `PermissionRequest` names can remain for compatibility, but the client code should convert them immediately into the normalized client vocabulary. It makes status transitions easier to read and avoids leaking hook-system naming into UI logic. + +Terminal exits should also be visible to the same controller: + +```ts +interface TerminalLifecycleEvent { + type: "terminal:lifecycle"; + workspaceId: string; + terminalId: string; + kind: "exited" | "killed" | "errored"; + exitCode?: number; + signal?: number; + occurredAt: number; +} +``` + +This is how v2 gets the v1 behavior of clearing stuck statuses without coupling to mounted terminal panes. + +### Layer 4: Client Notification Controller + +The client should have one controller per host URL, not one notification hook per workspace. + +Responsibilities: + +- subscribe to `agent:lifecycle` and `terminal:lifecycle` for all workspaces on a host +- keep a current index of v2 pane layout data: + - `terminalId -> { workspaceId, tabId, paneId }` + - `sessionId -> { workspaceId, tabId, paneId }` + - `resourceId -> { workspaceId, tabId, paneId }` when available +- resolve incoming events to a `NotificationTarget` +- update the attention store through pure transition functions +- suppress audio/toasts only when the target is actually visible and focused +- read notification preferences through a platform abstraction +- play ringtone through a platform abstraction +- show native/browser notifications through a platform abstraction +- handle click-to-focus through a platform abstraction + +Suggested shape: + +```ts +interface NotificationTarget { + workspaceId: string; + tabId?: string; + paneId?: string; + sourceKey: string; + sourceKind: "terminal" | "chat" | "automation" | "unknown"; +} + +interface NotificationPreferences { + soundsMuted: boolean; + volume: number; + selectedRingtoneId: string; + notificationsEnabled: boolean; +} + +interface NotificationPlatform { + getPreferences(): NotificationPreferences; + playRingtone(input: { ringtoneId: string; volume: number; muted: boolean }): void; + showNotification(input: { + target: NotificationTarget; + kind: "completed" | "waiting-for-input"; + silent: boolean; + }): void; + focusTarget(target: NotificationTarget): void; +} +``` + +Desktop can implement `NotificationPlatform` with Electron/local-db today. Web can implement it with Postgres-backed user settings, browser `Notification`, and `BroadcastChannel` leader election later. + +### Layer 5: Attention Store + +Do not treat `terminalId` as a fake `paneId`. It works for sidebar aggregation, but it obscures what the key actually means. + +Use an attention store keyed by a stable source key: + +```ts +type AttentionStatus = "working" | "permission" | "review"; + +interface AttentionEntry { + workspaceId: string; + sourceKey: string; + sourceKind: "terminal" | "chat" | "automation" | "unknown"; + status: AttentionStatus; + paneId?: string; + tabId?: string; + updatedAt: number; +} +``` + +Key examples: + +| Event identifiers | Source key | +| --- | --- | +| `terminalId=abc` | `terminal:abc` | +| `sessionId=abc` | `chat-session:abc` | +| `resourceId=abc` | `resource:abc` | +| no source ID | ignore for status, but may still play a generic chime if allowed | + +Workspace sidebar aggregation should reduce all entries for a workspace by priority: ```text -agent shell hook (notify.sh) - │ POST /trpc/notifications.hook (unauthenticated, loopback) - ▼ -host-service - ├── mapEventType() normalizes 20+ agent-specific strings to Start / Stop / PermissionRequest - └── EventBus.broadcastAgentLifecycle() - │ - ▼ fan out on the existing WebSocket event bus alongside git:changed / fs:events -renderer (desktop electron; web later) - ├── V2AgentHookListeners at _authenticated/layout.tsx — one listener per open v2 workspace - ├── useV2AgentHookListener(workspaceId) - │ ├── updatePaneStatus → useV2PaneStatusStore (working/permission/review) - │ ├── shouldSuppress → skip ringtone if user is viewing + window focused - │ ├── playRingtone → HTMLAudioElement with the 11 bundled v1 mp3s - │ └── new Notification() → native OS toast (silent, we play audio ourselves) - └── DashboardSidebarWorkspaceIcon renders the status dot (amber spinner / red pulse / static green) +permission > working > review > idle ``` -Electron main's v1 hook server (`apps/desktop/src/main/lib/notifications/server.ts`) stays running for v1 terminals. The shell script prefers the v2 host-service endpoint when `SUPERSET_HOST_AGENT_HOOK_URL` is set; falls back to v1 on missing URL or non-2xx response. +Pane/tab chrome can use `paneId` when resolution succeeds. The sidebar should still work when only a source key is available. + +## Status Transitions + +Status transitions should be pure and tested. + +| Incoming event | Prior status | Target visible and focused | Next status | +| --- | --- | --- | --- | +| `started` | any | any | `working` | +| `waiting-for-input` | any | any | `permission` | +| `completed` | `permission` | any | clear | +| `completed` | any | yes | clear | +| `completed` | any | no | `review` | +| `terminal exited/killed/errored` | `working` or `permission` | any | clear | +| user views target with `review` | `review` | yes | clear | + +Optional hardening: + +- expire stale `working` statuses after a long TTL if no stop/exit arrives +- expire stale `permission` statuses only after the source is known dead +- keep `review` until acknowledged or workspace closes + +## Suppression Rules -## What shipped +Suppression should be target-based, not workspace-based. -### host-service (hook ingress + broadcast) +Correct behavior: -- **`packages/host-service/src/events/map-event-type.ts`** — normalizes arbitrary agent event names to `Start | Stop | PermissionRequest | null`. Ported from `apps/desktop/src/main/lib/notifications/map-event-type.ts` (duplicated per the v1/v2 duplication memory — v1 dies with the v1 UI sunset, so no shared extraction). -- **`packages/host-service/src/events/types.ts`** — `AgentLifecycleMessage` added to the `ServerMessage` union. Fields: `workspaceId`, `eventType`, optional `paneId` / `tabId` / `terminalId` / `sessionId` / `hookSessionId` / `resourceId`, `occurredAt`. -- **`packages/host-service/src/events/event-bus.ts`** — `broadcastAgentLifecycle()` public method; fans out to all connected sockets, matching the existing `git:changed` pattern (workspaceId filtering happens client-side). -- **`packages/host-service/src/trpc/router/notifications/`** — `notifications.hook` mutation. **`publicProcedure`**. Input shape mirrors v1's `/hook/complete` query string so the same shell script can speak both. On valid input: call `ctx.eventBus.broadcastAgentLifecycle(...)`. -- **`packages/host-service/src/types.ts`** + **`app.ts`** — `eventBus` added to the tRPC context so the mutation can reach it. -- **`packages/host-service/src/terminal/env.ts`** + **`terminal.ts`** — `buildV2TerminalEnv` now injects `SUPERSET_HOST_AGENT_HOOK_URL` (`http://127.0.0.1:$HOST_SERVICE_PORT/trpc/notifications.hook`) into v2 PTY env. No token — endpoint is unauth (see "Why no auth" below). +- If the app/tab is not focused, do not suppress. +- If the event resolves to a visible active pane, suppress sound and OS/browser notification. +- If the event resolves to a different pane in the same workspace, do not suppress. +- If the event cannot resolve beyond `workspaceId`, do not blindly suppress just because the workspace is visible. Prefer a generic notification over a missed completion. +- A `waiting-for-input` event may still show an in-app indicator even when sound is suppressed. -### workspace-client (wire format) +This differs from the shipped fallback, which suppresses by current workspace when `paneId` and `tabId` are absent. -- **`packages/workspace-client/src/lib/eventBus.ts`** — extended `EventType` with `"agent:lifecycle"`; added `AgentLifecyclePayload`; handler branch in `handleMessage` that passes the payload through. Multiple listeners against the same host reuse one WebSocket connection (existing pooling). -- **`packages/workspace-client/src/index.ts`** — re-exports `AgentLifecyclePayload`. +## Notification Click Behavior -### renderer (playback + sidebar) +Click handling is part of parity and should not be optional. -- **`apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/`** — overload for `"agent:lifecycle"` next to the existing `git:changed` / `fs:events` overloads. -- **`apps/desktop/src/renderer/lib/ringtones/urls.ts`** — Vite-bundled URLs for the 11 v1 ringtones via `new URL("../../../resources/sounds/", import.meta.url)`. Emits hashed asset URLs in prod, served from dev server in dev, without copying files into `resources/public/`. -- **`apps/desktop/src/renderer/lib/ringtones/play.ts`** — `playRingtone({ ringtoneId, volume, muted })` (HTMLAudioElement, early-return on mute / 0 volume, silent catch on autoplay-blocked) + `primeRingtoneAudioOnFirstGesture()`. The primer is idempotent (repeated calls don't stack listeners), marks `audioPrimed` only after `silent.play()` resolves, and re-arms **both** pointerdown and keydown on failure — the first iteration dropped keydown on the retry path, which would have broken keyboard-only users. -- **`apps/desktop/src/renderer/stores/v2-pane-status/store.ts`** — `useV2PaneStatusStore` with `Record`. Separate from v1's `useTabsStore` because v2 paneIds aren't registered there. Exposes `setPaneStatus`, `clearPaneStatus`, `clearWorkspaceStatuses`, `clearWorkspaceAttention` (review-only clear, mirrors v1's `resetWorkspaceStatus`). `selectWorkspaceStatus(id)` selector aggregates non-idle statuses by workspaceId via `getHighestPriorityStatus`. -- **`apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/`**: - - `useV2AgentHookListener(workspaceId)` — subscribes via `useWorkspaceEvent("agent:lifecycle", ...)`, calls `updatePaneStatus` unconditionally, then `playRingtone` + `Notification` for non-Start events that pass suppression. - - `updatePaneStatus` — maps Start → `working`, PermissionRequest → `permission`, Stop → `idle` (if the user is viewing this workspace) or `review` otherwise. Uses `firstNonBlank(paneId, terminalId, sessionId, hookSessionId, resourceId)` as the store key. - - `shouldSuppress` — document hidden / window not focused → don't suppress. Full pane info → use `isPaneVisible`. Missing pane info (the v2 common case) → fall back to `isCurrentWorkspace` as the closest approximation. - - `isCurrentWorkspace` — matches both `/workspace/` and `/v2-workspace/` hash routes. - - `showNativeNotification` — `new Notification(title, { body, tag, silent: true })`. The `tag` also uses `firstNonBlank` so v2 events don't collide on `workspaceId:_`. - - `isPaneVisible.ts` — small local copy of main's `isPaneVisible` to avoid crossing the renderer/main boundary for a pure data helper. -- **`apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/`** — `V2AgentHookListeners` queries `collections.v2Workspaces` via `useLiveQuery`, renders one invisible `WorkspaceListener` per workspace. `WorkspaceListener` lives in its own file (one-component-per-file rule). Mounted at `_authenticated/layout.tsx` alongside `AgentHooks` — always active, whether or not the user is on a v2 workspace page, so backgrounded workspaces still flash the sidebar dot. -- **`apps/desktop/src/renderer/routes/_authenticated/layout.tsx`** — renders ``; calls `primeRingtoneAudioOnFirstGesture()` on mount. -- **`apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx`** — `useClearPaneAttentionOnView(workspaceId)` clears review statuses on mount AND whenever a new review arrives while the page is open (subscribes to `Object.values(s.statuses).some(...)` for presence so the effect re-fires on in-place arrivals). +On notification click: -### dashboard sidebar (status display) +- focus/restore the desktop window or browser tab when possible +- navigate to `/v2-workspace/$workspaceId` +- if `tabId` and `paneId` are known, activate them +- if only `terminalId` or `sessionId` is known, resolve it through pane layout and activate the matching pane +- if no pane can be resolved, navigate to the workspace and clear review attention for that source/workspace -- **`DashboardSidebarWorkspaceItem`** — subscribes via `useV2PaneStatusStore(selectWorkspaceStatus(id))`; threads `workspaceStatus` through both expanded and collapsed variants. -- **`DashboardSidebarExpandedWorkspaceRow`** — new `workspaceStatus?: ActivePaneStatus | null` prop, forwarded into the icon. -- **`DashboardSidebarWorkspaceIcon`** — already had the dot overlay and spinner machinery; it was just receiving `null`. Now gets real status → same visual as v1: amber `AsciiSpinner` when `working`, red pulsing dot on `permission`, static green dot on `review`. Same `StatusIndicator` component as v1 so the visual is pixel-identical. +V1 did this through Electron main emitting `FOCUS_TAB`. V2 should do it in the client controller through a platform-specific focus adapter. -### agent shell hook +## Ringtones And Preferences -- **`apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh`** — added a v2 branch above the existing v1 fallback. Builds a tRPC single-call JSON body (`{"json": {...}}` with superjson transformer), POSTs to `$SUPERSET_HOST_AGENT_HOOK_URL`, captures HTTP status. Exits only on `2xx`; otherwise falls through to the v1 electron endpoint (covers host-service restarts, crashes, transient 5xxs). Debug mode logs status on both paths. +The notification controller should depend on a preference provider, not directly on `electronTrpc`. -## Key decisions +Desktop phase: -- **No auth on `notifications.hook`.** The endpoint only broadcasts chimes — no code execution, no data access, no state change. Reusing the global `HOST_SERVICE_SECRET` as a bearer was both theater (the same secret already sits in a user-readable `~/.superset/host//manifest.json` alongside its port, so any user-level process can grab it) and a leak vector (PTY env exposure to every agent subprocess). We removed the token from PTY env entirely. If the endpoint ever grows real capabilities, re-introduce auth with a hook-scoped secret — not the global PSK. -- **V2 pane status in a separate store.** V2 panes live in `@superset/panes` (a workspace-scoped layout store with no `status` field). Piggybacking on v1's `useTabsStore` wouldn't work because v2 paneIds aren't registered there. `useV2PaneStatusStore` parallels the layout state and filters by workspaceId for sidebar derivation. -- **`terminalId` as the canonical v2 key.** V2 terminals set `SUPERSET_TERMINAL_ID` but not `SUPERSET_PANE_ID` — panes are a client-only concept in v2. The fallback chain is `paneId → terminalId → sessionId → hookSessionId → resourceId`, treating **empty strings as missing** (agents send `""` not `undefined`, so `??` was wrong — we use a `firstNonBlank` helper). -- **Listener at the layout, not per-page.** Mounted once on `_authenticated/layout.tsx` per v2 workspace via `V2AgentHookListeners`. Matches v1's global `useAgentHookListener`. Alternative (subscribe only for the currently-viewed workspace) was a behavior regression — users expect to hear the chime for workspace A while looking at workspace B. -- **Fallback to v1 on v2 failure.** Initial version `exit 0`-ed unconditionally after the v2 POST. Reviewers flagged that host-service restarts would silently drop notifications. Now captures status and only exits on 2xx — otherwise falls through to v1. +- read existing local-db settings through Electron tRPC +- keep built-in renderer playback +- keep `"custom"` fallback to default until custom playback is implemented + +Web phase: + +- move notification preferences to synced user settings +- store custom ringtones outside local filesystem, for example R2 plus IndexedDB cache +- add cross-tab leadership so only one tab plays sound + +Audio unlock should stay client-side. The current first-gesture priming is the right kind of workaround, but it should live with the notification platform implementation. + +## Host And Workspace Listener Topology + +Current topology: + +```text +authenticated layout + V2AgentHookListeners + one WorkspaceListener per workspace + useWorkspaceEvent("agent:lifecycle", workspaceId) +``` + +Recommended topology: + +```text +authenticated layout + AgentNotificationControllers + group open/known workspaces by host URL + one HostAgentNotificationController per host URL + eventBus.on("agent:lifecycle", "*") + eventBus.on("terminal:lifecycle", "*") + resolve event workspace/source locally +``` -## Review feedback addressed +Benefits: -- **`isCurrentWorkspace` matched `/workspace/` only** → v2 routes are `/v2-workspace/`, so Stop events always hit the `review` branch even when the user was viewing. Fixed to match both. -- **`shouldSuppress` dead for v2** → early-returned `false` when `paneId || tabId` missing, but v2 never populates those. Added workspace-level fallback. -- **Notification `tag` collision** → `paneId ?? sessionId ?? "_"` gave v2 events the same `_` tag, so each new notification replaced the previous. Uses `firstNonBlank` now. -- **`useClearPaneAttentionOnView` only ran on mount** → reviews arriving while the user was already on the page lingered on the sidebar. Now re-runs when a review appears for the viewed workspace. -- **`WorkspaceListener` in the same file as `V2AgentHookListeners`** → split per AGENTS.md one-component rule. -- **Autoplay priming listener stacking + dropped keyboard retry** → guarded with `audioPrimingListenersInstalled` and re-arm both pointer + keyboard on retry. -- **Plan doc drift** → `/hook/complete` → `/trpc/notifications.hook`. -- **`HOST_SERVICE_SECRET` in PTY env** → removed; endpoint is now unauth. +- one subscription path per host +- one set of notification settings reads +- one place for click handling and suppression +- easier web reuse +- easier tests -## What we didn't do +The event bus already supports `workspaceId: "*"`, so this is mostly a client refactor. -- **Postgres-synced prefs.** Renderer still reads `notificationVolume` / `notificationSoundsMuted` / `selectedRingtoneId` via electron-trpc from local-db. Fine for desktop-only usage. Migrating to Postgres `userSettings` is a follow-up; ship when the web client needs pref sync across devices. -- **Custom ringtones.** v1 supports a single user-uploaded `.mp3` on local filesystem. V2 treats the `"custom"` id as fallback-to-default for now. To ship: R2 upload + IndexedDB cache + one-shot local→R2 migration. Gate on telemetry — worth checking if anyone actually used the feature before investing in storage infra. -- **Web client subscription.** `apps/web` doesn't connect to host-service's event bus yet. The rendering path is already web-compatible (no electron IPC in `playRingtone`, `useV2AgentHookListener`, or the pane-status store). Same hooks should drop in once apps/web has a host-service connection. -- **Cross-tab dedup** (for the web client). If the web client is ever opened in two tabs, both chime. Plan called for `BroadcastChannel` leader election; skip until it's a real problem. -- **Missed events while disconnected.** WebSocket is lossy on reconnect. Fire-and-forget is acceptable for chimes. If "I missed 3 completions" matters, persist hook events in host-service and replay with a `since` cursor. -- **Retiring v1 electron-main audio.** `apps/desktop/src/main/lib/notifications/server.ts`, `play-sound.ts`, and `custom-ringtones.ts` stay for v1 terminals. Delete when v1 UI sunsets (see `project_v1_sunset`). The shell script's v1 fallback can go at the same time. -- **Native dock/tray integrations.** Electron-specific dock bounce, tray flash, etc. Out of scope — browser `Notification` works on all platforms inside Electron. +## Testing Plan -## Risks / open questions +Add tests before expanding the behavior further. + +Host-service unit tests: + +- `mapEventType` maps every v1-supported raw event name. +- unknown and empty event types return ignored success. +- missing `workspaceId` returns ignored success. +- valid hook input broadcasts exactly one normalized event. +- public hook endpoint does not expose workspace data in responses. +- rate limiting/payload limits when implemented. + +Workspace-client tests: + +- `agent:lifecycle` messages dispatch to matching workspace listeners. +- wildcard listeners receive all workspace events. +- reconnect preserves active subscriptions. + +Renderer/client unit tests: + +- identity resolver maps `terminalId`, `sessionId`, and `resourceId` to v2 pane locations. +- status transition table is covered. +- terminal exit clears `working` and `permission`. +- review clears when the user views the target. +- suppression only happens for focused, visible target panes. +- unresolved workspace-only events are not over-suppressed. +- notification click calls the focus adapter with the resolved target. +- custom ringtone falls back consistently until full support lands. + +Integration tests: + +- shell hook POST -> host-service event bus -> client controller receives event. +- remote/relay host URL uses the same event path. +- v1 fallback still works when `SUPERSET_HOST_AGENT_HOOK_URL` is absent or non-2xx. + +Manual QA: + +- local v2 terminal completes while current pane visible: sidebar clears, no chime. +- local v2 terminal completes in background workspace: chime, sidebar review dot. +- permission request in background workspace: chime, sidebar permission dot. +- kill/interruption clears working/permission. +- notification click focuses the right workspace. +- mute/volume/ringtone settings apply. +- host-service restart does not permanently duplicate listeners. + +## Implementation Plan + +### Phase 1: Stabilize The MVP + +- Add tests for host-service event mapping and hook mutation. +- Extract v2 status transition logic into pure functions. +- Add terminal lifecycle events to host-service event bus. +- Clear v2 `working` and `permission` statuses on terminal exit. +- Add click handling for v2 notifications. +- Fix suppression to resolve by `terminalId` / `sessionId` / `resourceId` before falling back. + +### Phase 2: Refactor Ownership + +- Replace per-workspace listeners with per-host notification controllers. +- Rename `v2-pane-status` or wrap it as an agent attention store keyed by source key, not fake pane ID. +- Add a pane-layout identity index for v2. +- Introduce a `NotificationPlatform` abstraction for preferences, playback, browser/OS notification, and focus. +- Keep desktop implementation backed by existing Electron/local-db APIs. + +### Phase 3: Web Readiness + +- Move preferences to synced user settings. +- Add browser notification permission UI. +- Add BroadcastChannel leadership for cross-tab dedup. +- Add web-compatible ringtone asset and custom ringtone loading. +- Reuse the same controller with a web platform adapter. + +### Phase 4: V1 Retirement + +- Keep the v1 hook server until v1 workspace UI is gone. +- During retirement, remove: + - Electron main notification playback + - v1 localhost hook server + - duplicated event mappers + - v1 pane-status notification paths +- Keep only the host-service ingest and client notification controller. + +## File Shape Recommendation + +Suggested future layout: + +```text +packages/host-service/src/events/ + agent-lifecycle.ts # event types, normalization exports + event-bus.ts + +packages/host-service/src/trpc/router/notifications/ + notifications.ts # low-capability ingest only + notifications.test.ts + +packages/workspace-client/src/lib/ + eventBus.ts # typed wildcard and per-workspace events + +apps/desktop/src/renderer/routes/_authenticated/components/AgentNotificationControllers/ + AgentNotificationControllers.tsx + components/HostAgentNotificationController/ + HostAgentNotificationController.tsx + hooks/useAgentNotificationController/ + useAgentNotificationController.ts + resolveNotificationTarget.ts + notificationTransitions.ts + notificationSuppression.ts + *.test.ts + +apps/desktop/src/renderer/stores/agent-attention/ + store.ts + selectors.ts + +apps/desktop/src/renderer/lib/notifications/ + desktopNotificationPlatform.ts + ringtonePlayback.ts +``` -- **Mobile Safari autoplay policy** (when web client ships). Stricter about unprimed audio than desktop Chrome. If the user's first interaction is returning to a backgrounded tab after a hook fires, the sound may be blocked. The `Notification` toast still fires so it degrades gracefully. -- **Multi-device simultaneous play.** Arguably correct (like email notifications on phone + laptop). No dedup. -- **Relay exposure of the unauth endpoint.** If host-service is exposed via relay, anyone who can reach the relay's proxy for this host can POST fake `notifications.hook` events. Concrete impact: nuisance chimes. No state change, no data. We accept this. +When this moves to web, create a web platform adapter rather than forking the lifecycle logic. -## Sequencing (context, not current) +## Acceptance Criteria -Shipping order in this PR: -1. Pipeline plumbing (map-event-type, event-bus channel, tRPC mutation, terminal-env URL injection, workspace-client, useWorkspaceEvent overload) — `f6aed52f4` -2. Renderer side (playRingtone, useV2AgentHookListener, sidebar wiring, status store) — `7767b729f` -3. Layout-level listener + debug-log cleanup — `6acb4a340` -4. Review fixes — `e6dab1864` -5. Drop auth + remove PSK from PTY — `87d079689` -6. v1 fallback + safer priming — `103c00a17` +This design is done when: -Postgres prefs + R2 custom ringtones follow in a separate PR when gated on user need. +- v2 completion and permission notifications work for local and remote host-service. +- v2 has parity with v1 for sound, mute, volume, suppression, click-to-focus, and terminal-exit cleanup. +- the notification controller is client-side and host-agnostic. +- host-service remains a low-capability event ingress layer. +- no broad host-service secret is exposed to agent PTY env. +- behavior is covered by unit tests for normalization, identity resolution, transitions, suppression, and click handling. +- web can reuse the controller by swapping the platform adapter. -## Commit trail +## Security Rule For Future Changes -- `f6aed52f4` — initial v2 notification hook pipeline (map-event-type, event-bus, tRPC, terminal-env, workspace-client, ringtones, useV2AgentHookListener, layout priming, plan doc) -- `7767b729f` — v2 sidebar status store + terminalId payload + empty-string coalesce fix -- `6acb4a340` — hoist listener to authenticated layout + drop debug logs -- `e6dab1864` — review fixes (v2 route regex, v2 suppression, notif tag, component split, attention clear, plan doc) -- `87d079689` — drop auth on `notifications.hook`, remove `HOST_SERVICE_SECRET` from PTY env -- `103c00a17` — v1 fallback on v2 non-2xx + idempotent autoplay priming -- `828ca8c21` — this doc +Any future change that makes `notifications.hook` do more than broadcast generic lifecycle attention must re-open the auth design. The endpoint is intentionally public only because it is low-impact. Do not add state mutation, data reads, arbitrary user-visible content, or command execution behind the same unauthenticated route. From c6696c1cf53d120beb35ebd6cb6351dea059eb0c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 15:55:15 -0700 Subject: [PATCH 09/17] fix(desktop): simplify v2 notification hook identity --- .../main/lib/agent-setup/notify-hook.test.ts | 7 +- .../templates/notify-hook.template.sh | 14 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 2 +- .../resolveV2NotificationTarget.test.ts | 53 ++----- .../resolveV2NotificationTarget.ts | 147 ++---------------- .../statusTransitions.test.ts | 14 +- .../statusTransitions.ts | 4 +- .../useV2AgentHookListener.ts | 12 +- packages/host-service/src/events/types.ts | 7 +- .../notifications/notifications.test.ts | 106 +++++++++++++ .../router/notifications/notifications.ts | 40 +++-- packages/workspace-client/src/lib/eventBus.ts | 12 +- ...60422-v2-notification-hooks-client-side.md | 44 ++---- 13 files changed, 199 insertions(+), 263 deletions(-) create mode 100644 packages/host-service/src/trpc/router/notifications/notifications.test.ts diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 51a5de2626f..d5b1c9380ed 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; describe("getNotifyScriptContent", () => { - it("prefers Mastra resourceId over internal session_id", () => { + it("keeps v1 fallback session ids out of the v2 host-service payload", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", @@ -13,12 +13,15 @@ describe("getNotifyScriptContent", () => { expect(script).toContain( "SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}", ); + expect(script).toContain( + 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"', + ); expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"'); expect(script).toContain( '--data-urlencode "hookSessionId=$HOOK_SESSION_ID"', ); expect(script).toContain( - "event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", + "event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", ); }); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 0a18e93b91d..7259ad9c509 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -19,8 +19,14 @@ if [ -z "$RESOURCE_ID" ]; then fi SESSION_ID=${RESOURCE_ID:-$HOOK_SESSION_ID} -# Skip if this isn't a Superset terminal hook and no Mastra session context exists -[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 +# v2 terminal hooks identify the runtime by terminalId. The v1 fallback still +# uses pane/tab/session fields, so keep its legacy guard when no host-service +# hook URL is available. +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then + [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 +else + [ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 +fi # Extract event type - Claude uses "hook_event_name", Codex uses "type" # Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" @@ -68,7 +74,7 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 fi # Escape backslashes and double quotes for safe JSON embedding. @@ -83,7 +89,7 @@ json_escape() { # so we can fall back to v1 when host-service is unreachable or the # mutation returns non-2xx (restarts, crashes, transient errors). if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then - PAYLOAD="{\"json\":{\"paneId\":\"$(json_escape "$SUPERSET_PANE_ID")\",\"tabId\":\"$(json_escape "$SUPERSET_TAB_ID")\",\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\",\"hookSessionId\":\"$(json_escape "$HOOK_SESSION_ID")\",\"resourceId\":\"$(json_escape "$RESOURCE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"env\":\"$(json_escape "$SUPERSET_ENV")\",\"version\":\"$(json_escape "$SUPERSET_HOOK_VERSION")\"}}" + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}" STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 2 --max-time 5 \ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 03ad269eb7a..77496cadfc0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -157,7 +157,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< hostIsOnline={hostIsOnline} isActive={isActive} variant="expanded" - workspaceStatus={null} + workspaceStatus={workspaceStatus} creationStatus={creationStatus} pullRequestState={pullRequest.state} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts index 45834887c87..52ecb62b06b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts @@ -26,23 +26,23 @@ const layout: WorkspaceState = { kind: "terminal", data: { terminalId: "terminal-1" }, }, - "pane-chat-hidden": { - id: "pane-chat-hidden", - kind: "chat", - data: { sessionId: "chat-1" }, + "pane-terminal-hidden": { + id: "pane-terminal-hidden", + kind: "terminal", + data: { terminalId: "terminal-hidden" }, }, }, }, { id: "tab-background", createdAt: 2, - activePaneId: "pane-chat-background", - layout: { type: "pane", paneId: "pane-chat-background" }, + activePaneId: "pane-terminal-background", + layout: { type: "pane", paneId: "pane-terminal-background" }, panes: { - "pane-chat-background": { - id: "pane-chat-background", - kind: "chat", - data: { sessionId: "chat-2" }, + "pane-terminal-background": { + id: "pane-terminal-background", + kind: "terminal", + data: { terminalId: "terminal-2" }, }, }, }, @@ -54,6 +54,7 @@ function payload( ): AgentLifecyclePayload { return { eventType: "Stop", + terminalId: "terminal-1", occurredAt: 1, ...overrides, }; @@ -71,28 +72,11 @@ describe("resolveV2NotificationTarget", () => { workspaceId: WORKSPACE_ID, tabId: "tab-active", paneId: "pane-terminal", - sourceId: "terminal-1", terminalId: "terminal-1", }); }); - it("uses chat session ids to find the owning v2 pane", () => { - const target = resolveV2NotificationTarget({ - workspaceId: WORKSPACE_ID, - payload: payload({ resourceId: "chat-2" }), - paneLayout: layout, - }); - - expect(target).toMatchObject({ - workspaceId: WORKSPACE_ID, - tabId: "tab-background", - paneId: "pane-chat-background", - sourceId: "chat-2", - chatSessionId: "chat-2", - }); - }); - - it("falls back to a source-only target when no pane matches", () => { + it("falls back to a terminal-only target when no pane matches", () => { const target = resolveV2NotificationTarget({ workspaceId: WORKSPACE_ID, payload: payload({ terminalId: "terminal-missing" }), @@ -101,7 +85,6 @@ describe("resolveV2NotificationTarget", () => { expect(target).toEqual({ workspaceId: WORKSPACE_ID, - sourceId: "terminal-missing", terminalId: "terminal-missing", }); }); @@ -114,7 +97,7 @@ describe("resolveV2NotificationTarget", () => { }); const backgroundTarget = resolveV2NotificationTarget({ workspaceId: WORKSPACE_ID, - payload: payload({ sessionId: "chat-2" }), + payload: payload({ terminalId: "terminal-2" }), paneLayout: layout, }); @@ -137,11 +120,9 @@ describe("resolveV2NotificationTarget", () => { ).toBe(false); }); - it("prefers stable runtime ids over legacy pane ids for status keys", () => { - expect( - getNotificationSourceId( - payload({ paneId: "legacy-pane", terminalId: "terminal-1" }), - ), - ).toBe("terminal-1"); + it("uses the terminal id as the status key", () => { + expect(getNotificationSourceId(payload({ terminalId: "terminal-1" }))).toBe( + "terminal-1", + ); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts index db4025e2f0a..3972d1d6468 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts @@ -1,62 +1,24 @@ import type { WorkspaceState } from "@superset/panes"; import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import type { - ChatPaneData, - PaneViewerData, - TerminalPaneData, -} from "../../types"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; export interface V2NotificationTarget { workspaceId: string; tabId?: string; paneId?: string; - sourceId: string | null; - terminalId?: string; - chatSessionId?: string; -} - -export function firstNonBlank( - ...values: (string | undefined | null)[] -): string | null { - for (const value of values) { - if (value && value.trim().length > 0) return value; - } - return null; + terminalId: string; } export function getNotificationSourceId( - payload: Pick< - AgentLifecyclePayload, - "paneId" | "terminalId" | "sessionId" | "hookSessionId" | "resourceId" - >, -): string | null { - return firstNonBlank( - payload.terminalId, - payload.sessionId, - payload.hookSessionId, - payload.resourceId, - payload.paneId, - ); + payload: Pick, +): string { + return payload.terminalId; } export function getNotificationSourceIds( - payload: Pick< - AgentLifecyclePayload, - "paneId" | "terminalId" | "sessionId" | "hookSessionId" | "resourceId" - >, + payload: Pick, ): string[] { - const ids = new Set(); - for (const value of [ - payload.terminalId, - payload.sessionId, - payload.hookSessionId, - payload.resourceId, - payload.paneId, - ]) { - const id = firstNonBlank(value); - if (id) ids.add(id); - } - return [...ids]; + return [payload.terminalId]; } export function resolveV2NotificationTarget({ @@ -68,60 +30,26 @@ export function resolveV2NotificationTarget({ payload: AgentLifecyclePayload; paneLayout: WorkspaceState | null | undefined; }): V2NotificationTarget { - const sourceId = getNotificationSourceId(payload); - const tabId = firstNonBlank(payload.tabId); - const paneId = firstNonBlank(payload.paneId); - const terminalId = firstNonBlank(payload.terminalId); - if (tabId && paneId) { - return { - workspaceId, - tabId, - paneId, - sourceId, - terminalId: terminalId ?? undefined, - chatSessionId: getChatSessionId(payload) ?? undefined, - }; - } - - const terminalTarget = terminalId - ? resolveTerminalTarget({ - workspaceId, - terminalId, - paneLayout, - sourceId, - }) - : null; - if (terminalTarget) return terminalTarget; - - const chatSessionId = getChatSessionId(payload); - if (chatSessionId) { - const chatTarget = resolveChatTarget({ + return ( + resolveTerminalTarget({ workspaceId, - chatSessionId, + terminalId: payload.terminalId, paneLayout, - sourceId, - }); - if (chatTarget) return chatTarget; - } - - return { - workspaceId, - sourceId, - terminalId: terminalId ?? undefined, - chatSessionId: chatSessionId ?? undefined, - }; + }) ?? { + workspaceId, + terminalId: payload.terminalId, + } + ); } export function resolveTerminalTarget({ workspaceId, terminalId, paneLayout, - sourceId = terminalId, }: { workspaceId: string; terminalId: string; paneLayout: WorkspaceState | null | undefined; - sourceId?: string | null; }): V2NotificationTarget | null { if (!paneLayout?.tabs) return null; @@ -134,7 +62,6 @@ export function resolveTerminalTarget({ workspaceId, tabId: tab.id, paneId: pane.id, - sourceId, terminalId, }; } @@ -164,47 +91,3 @@ export function isV2NotificationTargetVisible({ tab?.activePaneId === target.paneId && paneLayout.activeTabId === tab.id ); } - -function resolveChatTarget({ - workspaceId, - chatSessionId, - paneLayout, - sourceId, -}: { - workspaceId: string; - chatSessionId: string; - paneLayout: WorkspaceState | null | undefined; - sourceId: string | null; -}): V2NotificationTarget | null { - if (!paneLayout?.tabs) return null; - - for (const tab of paneLayout.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind !== "chat") continue; - const data = pane.data as Partial; - if (data.sessionId !== chatSessionId) continue; - return { - workspaceId, - tabId: tab.id, - paneId: pane.id, - sourceId, - chatSessionId, - }; - } - } - - return null; -} - -function getChatSessionId( - payload: Pick< - AgentLifecyclePayload, - "sessionId" | "hookSessionId" | "resourceId" - >, -): string | null { - return firstNonBlank( - payload.sessionId, - payload.hookSessionId, - payload.resourceId, - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts index 5ec2b7f3252..cc4f1760df6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts @@ -9,7 +9,6 @@ const target: V2NotificationTarget = { workspaceId: WORKSPACE_ID, tabId: "tab-1", paneId: "pane-1", - sourceId: "terminal-1", terminalId: "terminal-1", }; @@ -18,19 +17,19 @@ function payload( ): AgentLifecyclePayload { return { eventType: "Stop", + terminalId: "terminal-1", occurredAt: 1, ...overrides, }; } describe("resolveV2AgentStatusTransition", () => { - it("marks start as working on the stable source id and clears alternates", () => { + it("marks start as working on the terminal id and clears pane aliases", () => { expect( resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, payload: payload({ eventType: "Start", - paneId: "legacy-pane", terminalId: "terminal-1", }), target, @@ -38,28 +37,27 @@ describe("resolveV2AgentStatusTransition", () => { targetVisible: false, }), ).toEqual({ - clearIds: ["legacy-pane", "pane-1"], + clearIds: ["pane-1"], setStatus: { id: "terminal-1", status: "working" }, }); }); - it("clears permission state on stop even when permission was keyed by an alternate id", () => { + it("clears permission state on stop even when permission was keyed by the pane id", () => { expect( resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, payload: payload({ eventType: "Stop", - paneId: "legacy-pane", terminalId: "terminal-1", }), target, statuses: { - "legacy-pane": { workspaceId: WORKSPACE_ID, status: "permission" }, + "pane-1": { workspaceId: WORKSPACE_ID, status: "permission" }, }, targetVisible: false, }), ).toEqual({ - clearIds: ["terminal-1", "legacy-pane", "pane-1"], + clearIds: ["terminal-1", "pane-1"], setStatus: null, }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts index bb1b7a41468..95ebdb108ff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts @@ -29,9 +29,7 @@ export function resolveV2AgentStatusTransition({ const statusIds = new Set(getNotificationSourceIds(payload)); if (target.paneId) statusIds.add(target.paneId); - const primaryId = target.sourceId ?? [...statusIds][0] ?? null; - if (!primaryId) return { clearIds: [], setStatus: null }; - + const primaryId = target.terminalId; statusIds.add(primaryId); const alternateIds = [...statusIds].filter((id) => id !== primaryId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 1e2fb370023..8893d374847 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -37,9 +37,9 @@ type Navigate = ReturnType; * pane is visible and the window is focused, and honor the existing * mute/volume settings. * - * Mount once per v2 workspace you want to receive events for. The - * layout-level `V2AgentHookListenersMount` component iterates every open - * workspace so backgrounded workspaces also light up the sidebar. + * The layout-level `V2AgentHookListeners` component is the active mount path: + * it subscribes once per host so backgrounded workspaces also light up the + * sidebar. */ export function useV2AgentHookListener(workspaceId: string): void { const navigate = useNavigate(); @@ -122,7 +122,7 @@ export function handleV2AgentLifecycleEvent({ const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; void playRingtone({ ringtoneId, volume, muted }); - showNativeNotification(payload, workspaceId, target, () => { + showNativeNotification(payload, workspaceId, () => { openNotificationTarget(navigate, workspaceId, target); }); } @@ -214,7 +214,6 @@ function shouldSuppress( function showNativeNotification( payload: AgentLifecyclePayload, workspaceId: string, - target: V2NotificationTarget, onClick: () => void, ): void { if (typeof Notification === "undefined") return; @@ -226,7 +225,7 @@ function showNativeNotification( ? "Your agent needs input" : "Your agent has finished"; - const tagId = target.sourceId ?? getNotificationSourceId(payload) ?? "_"; + const tagId = getNotificationSourceId(payload); try { const notification = new Notification(title, { @@ -273,7 +272,6 @@ function openNotificationTarget( params: { workspaceId }, search: { terminalId: target.terminalId, - chatSessionId: target.chatSessionId, }, }); } diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 5de8de37279..190fd0662e8 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -25,12 +25,7 @@ export interface AgentLifecycleMessage { type: "agent:lifecycle"; workspaceId: string; eventType: AgentLifecycleEventType; - paneId?: string; - tabId?: string; - terminalId?: string; - sessionId?: string; - hookSessionId?: string; - resourceId?: string; + terminalId: string; occurredAt: number; } diff --git a/packages/host-service/src/trpc/router/notifications/notifications.test.ts b/packages/host-service/src/trpc/router/notifications/notifications.test.ts new file mode 100644 index 00000000000..381492d416f --- /dev/null +++ b/packages/host-service/src/trpc/router/notifications/notifications.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { AgentLifecycleEventType } from "../../../events"; +import type { HostServiceContext } from "../../../types"; +import { notificationsRouter } from "./notifications"; + +interface BroadcastedAgentLifecycleEvent { + workspaceId: string; + eventType: AgentLifecycleEventType; + terminalId: string; + occurredAt: number; +} + +function createContext(originWorkspaceId: string | null): { + ctx: HostServiceContext; + broadcastAgentLifecycle: ReturnType< + typeof mock<(event: BroadcastedAgentLifecycleEvent) => void> + >; + findFirst: ReturnType; +} { + const broadcastAgentLifecycle = mock( + (_event: BroadcastedAgentLifecycleEvent) => {}, + ); + const findFirst = mock(() => ({ + sync: () => + originWorkspaceId === null + ? null + : { + originWorkspaceId, + }, + })); + + const ctx = { + db: { + query: { + terminalSessions: { + findFirst, + }, + }, + }, + eventBus: { + broadcastAgentLifecycle, + }, + } as unknown as HostServiceContext; + + return { ctx, broadcastAgentLifecycle, findFirst }; +} + +describe("notificationsRouter.hook", () => { + it("derives workspaceId from terminalId before broadcasting", async () => { + const { ctx, broadcastAgentLifecycle, findFirst } = + createContext("workspace-1"); + const caller = notificationsRouter.createCaller(ctx); + + const result = await caller.hook({ + terminalId: "terminal-1", + eventType: "task_complete", + }); + + expect(result).toEqual({ success: true, ignored: false }); + expect(findFirst).toHaveBeenCalledTimes(1); + expect(broadcastAgentLifecycle).toHaveBeenCalledTimes(1); + expect(broadcastAgentLifecycle.mock.calls[0]?.[0]).toMatchObject({ + workspaceId: "workspace-1", + eventType: "Stop", + terminalId: "terminal-1", + }); + expect(typeof broadcastAgentLifecycle.mock.calls[0]?.[0].occurredAt).toBe( + "number", + ); + }); + + it("ignores missing or unknown terminal ids", async () => { + const missingTerminal = createContext("workspace-1"); + const missingResult = await notificationsRouter + .createCaller(missingTerminal.ctx) + .hook({ eventType: "Stop" }); + + expect(missingResult).toEqual({ success: true, ignored: true }); + expect(missingTerminal.findFirst).not.toHaveBeenCalled(); + expect(missingTerminal.broadcastAgentLifecycle).not.toHaveBeenCalled(); + + const unknownTerminal = createContext(null); + const unknownResult = await notificationsRouter + .createCaller(unknownTerminal.ctx) + .hook({ terminalId: "terminal-missing", eventType: "Stop" }); + + expect(unknownResult).toEqual({ success: true, ignored: true }); + expect(unknownTerminal.findFirst).toHaveBeenCalledTimes(1); + expect(unknownTerminal.broadcastAgentLifecycle).not.toHaveBeenCalled(); + }); + + it("ignores unknown event types before looking up the terminal", async () => { + const { ctx, broadcastAgentLifecycle, findFirst } = + createContext("workspace-1"); + const caller = notificationsRouter.createCaller(ctx); + + const result = await caller.hook({ + terminalId: "terminal-1", + eventType: "unknown-event", + }); + + expect(result).toEqual({ success: true, ignored: true }); + expect(findFirst).not.toHaveBeenCalled(); + expect(broadcastAgentLifecycle).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index bd2c0f59b1f..e231ad7e24c 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -1,32 +1,25 @@ +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { terminalSessions } from "../../../db/schema"; import { mapEventType } from "../../../events"; import { publicProcedure, router } from "../../index"; /** - * Input shape matches the v1 `/hook/complete` query-string contract so the - * agent shell hook (notify-hook.template.sh) can point at either endpoint - * during the v1→v2 transition. Fields are optional because different agent - * runtimes emit different subsets. + * v2 terminal hook payload. The shell hook sends only stable runtime identity; + * host-service derives workspace identity from its terminal session table. */ const hookInput = z.object({ - paneId: z.string().optional(), - tabId: z.string().optional(), terminalId: z.string().optional(), - workspaceId: z.string().optional(), - sessionId: z.string().optional(), - hookSessionId: z.string().optional(), - resourceId: z.string().optional(), eventType: z.string().optional(), - env: z.string().optional(), - version: z.string().optional(), }); export const notificationsRouter = router({ /** * Agent lifecycle hook. The agent shell script POSTs here on * session-start / permission-request / task-complete events. We normalize - * the event type and fan out over the WebSocket event bus so clients - * (desktop renderer, web) can play the finish sound themselves. + * the event type, resolve the terminal's workspace, and fan out over the + * WebSocket event bus so clients (desktop renderer, web) can play the + * finish sound themselves. * * Intentionally unauthenticated. The only thing a caller can do is * cause clients to chime and flash a sidebar indicator — no code @@ -41,19 +34,24 @@ export const notificationsRouter = router({ return { success: true, ignored: true as const }; } - if (!input.workspaceId) { + if (!input.terminalId) { + return { success: true, ignored: true as const }; + } + + const terminalSession = ctx.db.query.terminalSessions + .findFirst({ + where: eq(terminalSessions.id, input.terminalId), + columns: { originWorkspaceId: true }, + }) + .sync(); + if (!terminalSession?.originWorkspaceId) { return { success: true, ignored: true as const }; } ctx.eventBus.broadcastAgentLifecycle({ - workspaceId: input.workspaceId, + workspaceId: terminalSession.originWorkspaceId, eventType, - paneId: input.paneId, - tabId: input.tabId, terminalId: input.terminalId, - sessionId: input.sessionId, - hookSessionId: input.hookSessionId, - resourceId: input.resourceId, occurredAt: Date.now(), }); diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index f33e7250905..24cef2ee840 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -25,12 +25,7 @@ export interface GitChangedPayload { export interface AgentLifecyclePayload { eventType: AgentLifecycleEventType; - paneId?: string; - tabId?: string; - terminalId?: string; - sessionId?: string; - hookSessionId?: string; - resourceId?: string; + terminalId: string; occurredAt: number; } @@ -131,12 +126,7 @@ function handleMessage(state: ConnectionState, data: unknown): void { message.workspaceId, { eventType: message.eventType, - paneId: message.paneId, - tabId: message.tabId, terminalId: message.terminalId, - sessionId: message.sessionId, - hookSessionId: message.hookSessionId, - resourceId: message.resourceId, occurredAt: message.occurredAt, }, ); diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index bfe1d2d155b..73c4c7f152f 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -115,7 +115,7 @@ The current implementation is useful but incomplete. - **No v2 terminal-exit cleanup.** V1 clears stuck `working` and `permission` statuses when a terminal exits. V2 status only changes on hook events, so interrupted or killed agents can leave a stale sidebar indicator. - **No notification click routing.** V1 notification clicks focus the app and route to the target workspace/tab/pane. V2 creates a `Notification` but does not handle clicks. -- **Suppression is too coarse.** If the v2 event lacks `paneId` and `tabId`, suppression falls back to "current workspace is visible." That can suppress a notification for a background pane in the same workspace. The client has v2 pane layout data and should resolve by `terminalId`, `sessionId`, or `resourceId` instead. +- **Suppression is too coarse.** If the v2 event lacks `paneId` and `tabId`, suppression falls back to "current workspace is visible." That can suppress a notification for a background pane in the same workspace. The client has v2 pane layout data and should resolve by `terminalId` instead. - **One listener per workspace is more work than needed.** Event-bus connections are reused per host, but each workspace still mounts a hook and settings queries. A host-level controller should subscribe once per host and fan events into the store. - **The renderer hook is desktop-specific.** It imports `electronTrpc` for settings, so the current path is not actually web-ready. - **Browser notification permission is not handled.** The v2 client checks `Notification.permission` but does not request permission or route users to settings. @@ -167,18 +167,12 @@ The hook script should stay intentionally dumb: The script can continue to be defensive because hooks run in user shells with inconsistent payloads. Long term, wrappers should pass the normalized Superset identifiers directly so the script does less text parsing. -Required identifiers: +Required v2 hook payload: -- `workspaceId`: required for v2 -- one stable source ID: - - `terminalId` for terminal-backed agents - - `sessionId` or `resourceId` for chat-backed agents - - automation run ID when automation notifications move here +- `terminalId`: stable runtime identity for terminal-backed agents +- `eventType`: raw agent lifecycle event name -Optional identifiers: - -- `paneId` and `tabId`, when a client-side caller can provide them -- `hookSessionId`, for agent-runtime correlation/debugging +`workspaceId`, `paneId`, and `tabId` should not be part of the v2 hook payload. Host-service derives `workspaceId` from `terminalId`, and the renderer derives pane/tab visibility from its current v2 pane layout. ### Layer 2: Host-Service Notification Ingress @@ -188,7 +182,8 @@ Responsibilities: - accept the hook payload - reject oversized or malformed input -- require `workspaceId` +- require `terminalId` +- derive `workspaceId` from the terminal session table - ignore unknown event types - normalize raw event names into a small lifecycle vocabulary - attach `occurredAt` @@ -239,18 +234,7 @@ interface AgentLifecycleEvent { type: "agent:lifecycle"; workspaceId: string; kind: AgentLifecycleKind; - source: { - kind: "terminal" | "chat" | "automation" | "unknown"; - terminalId?: string; - sessionId?: string; - hookSessionId?: string; - resourceId?: string; - automationRunId?: string; - }; - pane?: { - paneId?: string; - tabId?: string; - }; + terminalId: string; rawEventType?: string; occurredAt: number; } @@ -283,8 +267,6 @@ Responsibilities: - subscribe to `agent:lifecycle` and `terminal:lifecycle` for all workspaces on a host - keep a current index of v2 pane layout data: - `terminalId -> { workspaceId, tabId, paneId }` - - `sessionId -> { workspaceId, tabId, paneId }` - - `resourceId -> { workspaceId, tabId, paneId }` when available - resolve incoming events to a `NotificationTarget` - update the attention store through pure transition functions - suppress audio/toasts only when the target is actually visible and focused @@ -350,8 +332,6 @@ Key examples: | Event identifiers | Source key | | --- | --- | | `terminalId=abc` | `terminal:abc` | -| `sessionId=abc` | `chat-session:abc` | -| `resourceId=abc` | `resource:abc` | | no source ID | ignore for status, but may still play a generic chime if allowed | Workspace sidebar aggregation should reduce all entries for a workspace by priority: @@ -405,7 +385,7 @@ On notification click: - focus/restore the desktop window or browser tab when possible - navigate to `/v2-workspace/$workspaceId` - if `tabId` and `paneId` are known, activate them -- if only `terminalId` or `sessionId` is known, resolve it through pane layout and activate the matching pane +- if only `terminalId` is known, resolve it through pane layout and activate the matching pane - if no pane can be resolved, navigate to the workspace and clear review attention for that source/workspace V1 did this through Electron main emitting `FOCUS_TAB`. V2 should do it in the client controller through a platform-specific focus adapter. @@ -469,7 +449,7 @@ Host-service unit tests: - `mapEventType` maps every v1-supported raw event name. - unknown and empty event types return ignored success. -- missing `workspaceId` returns ignored success. +- missing or unknown `terminalId` returns ignored success. - valid hook input broadcasts exactly one normalized event. - public hook endpoint does not expose workspace data in responses. - rate limiting/payload limits when implemented. @@ -482,7 +462,7 @@ Workspace-client tests: Renderer/client unit tests: -- identity resolver maps `terminalId`, `sessionId`, and `resourceId` to v2 pane locations. +- identity resolver maps `terminalId` to v2 pane locations. - status transition table is covered. - terminal exit clears `working` and `permission`. - review clears when the user views the target. @@ -516,7 +496,7 @@ Manual QA: - Add terminal lifecycle events to host-service event bus. - Clear v2 `working` and `permission` statuses on terminal exit. - Add click handling for v2 notifications. -- Fix suppression to resolve by `terminalId` / `sessionId` / `resourceId` before falling back. +- Fix suppression to resolve by `terminalId` before falling back. ### Phase 2: Refactor Ownership From 140fdf3f0333b08e1182a848ac18fb276cd0eff5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 17:04:04 -0700 Subject: [PATCH 10/17] refactor(desktop): normalize v2 notification state --- .../DashboardSidebarWorkspaceItem.tsx | 7 +- .../statusTransitions.ts | 4 +- .../useV2AgentHookListener.ts | 22 +- .../v2-workspace/$workspaceId/page.tsx | 55 +--- .../renderer/stores/v2-notifications/index.ts | 17 ++ .../stores/v2-notifications/store.test.ts | 91 +++++++ .../renderer/stores/v2-notifications/store.ts | 249 ++++++++++++++++++ .../renderer/stores/v2-pane-status/index.ts | 4 - .../renderer/stores/v2-pane-status/store.ts | 100 ------- ...60422-v2-notification-hooks-client-side.md | 6 +- 10 files changed, 387 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/src/renderer/stores/v2-notifications/index.ts create mode 100644 apps/desktop/src/renderer/stores/v2-notifications/store.test.ts create mode 100644 apps/desktop/src/renderer/stores/v2-notifications/store.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-pane-status/index.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-pane-status/store.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 0884506a5e0..efe053473a8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,10 +1,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; -import { - selectWorkspaceStatus, - useV2PaneStatusStore, -} from "renderer/stores/v2-pane-status"; +import { useV2WorkspaceNotificationStatus } from "renderer/stores/v2-notifications"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; @@ -39,7 +36,7 @@ export function DashboardSidebarWorkspaceItem({ creationStatus, } = workspace; const diffStats = useDiffStats(id); - const workspaceStatus = useV2PaneStatusStore(selectWorkspaceStatus(id)); + const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, handleClick, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts index 95ebdb108ff..7dc9385af18 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts @@ -1,5 +1,5 @@ import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import type { PaneStatus } from "shared/tabs-types"; +import type { ActivePaneStatus, PaneStatus } from "shared/tabs-types"; import type { V2NotificationTarget } from "./resolveV2NotificationTarget"; import { getNotificationSourceIds } from "./resolveV2NotificationTarget"; @@ -10,7 +10,7 @@ interface StatusEntry { export interface V2AgentStatusTransition { clearIds: string[]; - setStatus: { id: string; status: PaneStatus } | null; + setStatus: { id: string; status: ActivePaneStatus } | null; } export function resolveV2AgentStatusTransition({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 8893d374847..2a30d9e38df 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -12,7 +12,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useRingtoneStore } from "renderer/stores/ringtone"; -import { useV2PaneStatusStore } from "renderer/stores/v2-pane-status"; +import { useV2NotificationStore } from "renderer/stores/v2-notifications"; import type { PaneViewerData } from "../../types"; import { getNotificationSourceId, @@ -146,9 +146,8 @@ export function handleV2TerminalLifecycleEvent({ } /** - * Writes pane agent-lifecycle status into the v2 pane-status store so the - * dashboard sidebar icon can pick it up. V2 panes are not tracked in the - * v1 `useTabsStore`, so this is its own source of truth. + * Writes agent-lifecycle status into the v2 notification store so workspace, + * tab, and pane UI can derive attention from the same terminal source. * * The Stop transition mirrors v1 (useAgentHookListener.ts), but uses the v2 * pane layout instead of workspace-level guessing: clear to idle when the @@ -161,7 +160,7 @@ function updatePaneStatus( target: V2NotificationTarget, paneLayout: WorkspaceState | null | undefined, ): void { - const store = useV2PaneStatusStore.getState(); + const store = useV2NotificationStore.getState(); const targetVisible = isV2NotificationTargetVisible({ currentWorkspaceId: getCurrentWorkspaceId(), paneLayout, @@ -171,16 +170,17 @@ function updatePaneStatus( workspaceId, payload, target, - statuses: store.statuses, + statuses: store.sources, targetVisible, }); clearStatusIds(workspaceId, transition.clearIds); if (transition.setStatus) { - store.setPaneStatus( + store.setTerminalStatus( transition.setStatus.id, workspaceId, transition.setStatus.status, + payload.occurredAt, ); } } @@ -248,13 +248,9 @@ function clearStatusIds( workspaceId: string, ids: Array, ): void { - const store = useV2PaneStatusStore.getState(); + const store = useV2NotificationStore.getState(); const uniqueIds = new Set(ids.filter((id): id is string => Boolean(id))); - for (const id of uniqueIds) { - if (store.statuses[id]?.workspaceId === workspaceId) { - store.clearPaneStatus(id); - } - } + store.clearSourceStatuses(uniqueIds, workspaceId); } function openNotificationTarget( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 3f009b25b24..ef0c6d731b0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,5 +1,4 @@ import { - type Pane, type PaneActionConfig, Workspace, type WorkspaceStore, @@ -21,7 +20,11 @@ import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; -import { useV2PaneStatusStore } from "renderer/stores/v2-pane-status"; +import { + getV2NotificationSourceIdsForPane, + useV2NotificationStore, + useV2PaneNotificationStatus, +} from "renderer/stores/v2-notifications"; import { toAbsoluteWorkspacePath, toRelativeWorkspacePath, @@ -117,53 +120,23 @@ function useClearActivePaneAttention({ workspaceId: string; store: StoreApi>; }): void { - const activePaneKeys = useStore(store, (state) => { + const activePane = useStore(store, (state) => { const tab = state.tabs.find( (candidate) => candidate.id === state.activeTabId, ); - const pane = tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; - return getPaneAttentionKeys(pane).join("\u0000"); + return tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; }); - const clearPaneStatus = useV2PaneStatusStore( - (state) => state.clearPaneStatus, - ); - const hasActivePaneReview = useV2PaneStatusStore((state) => - activePaneKeys - .split("\u0000") - .filter(Boolean) - .some( - (key) => - state.statuses[key]?.workspaceId === workspaceId && - state.statuses[key]?.status === "review", - ), + const activePaneStatus = useV2PaneNotificationStatus(workspaceId, activePane); + const clearSourceAttention = useV2NotificationStore( + (state) => state.clearSourceAttention, ); useEffect(() => { - if (!hasActivePaneReview) return; - for (const key of activePaneKeys.split("\u0000").filter(Boolean)) { - const entry = useV2PaneStatusStore.getState().statuses[key]; - if (entry?.workspaceId === workspaceId && entry.status === "review") { - clearPaneStatus(key); - } + if (activePaneStatus !== "review") return; + for (const sourceId of getV2NotificationSourceIdsForPane(activePane)) { + clearSourceAttention(sourceId, workspaceId); } - }, [activePaneKeys, clearPaneStatus, hasActivePaneReview, workspaceId]); -} - -function getPaneAttentionKeys( - pane: Pane | undefined, -): string[] { - if (!pane) return []; - - const keys = new Set([pane.id]); - if (pane.kind === "terminal") { - const data = pane.data as TerminalPaneData; - if (data.terminalId) keys.add(data.terminalId); - } - if (pane.kind === "chat") { - const data = pane.data as ChatPaneData; - if (data.sessionId) keys.add(data.sessionId); - } - return [...keys]; + }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); } function WorkspaceContent({ diff --git a/apps/desktop/src/renderer/stores/v2-notifications/index.ts b/apps/desktop/src/renderer/stores/v2-notifications/index.ts new file mode 100644 index 00000000000..f660aabcec7 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/index.ts @@ -0,0 +1,17 @@ +export { + getV2NotificationSourceIdsForPane, + getV2NotificationSourceIdsForTab, + selectV2PaneNotificationStatus, + selectV2TabNotificationStatus, + selectV2TerminalNotificationStatus, + selectV2WorkspaceNotificationStatus, + useV2NotificationStore, + useV2PaneNotificationStatus, + useV2TabNotificationStatus, + useV2TerminalNotificationStatus, + useV2WorkspaceNotificationStatus, + type V2NotificationPaneLike, + type V2NotificationSource, + type V2NotificationState, + type V2NotificationTabLike, +} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts new file mode 100644 index 00000000000..1771b62d8c3 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + getV2NotificationSourceIdsForPane, + getV2NotificationSourceIdsForTab, + selectV2PaneNotificationStatus, + selectV2TabNotificationStatus, + selectV2TerminalNotificationStatus, + selectV2WorkspaceNotificationStatus, + useV2NotificationStore, +} from "./store"; + +const terminalPane = { + id: "pane-1", + kind: "terminal", + data: { terminalId: "terminal-1" }, +}; +const secondTerminalPane = { + id: "pane-2", + kind: "terminal", + data: { terminalId: "terminal-2" }, +}; +const chatPane = { + id: "pane-3", + kind: "chat", + data: { sessionId: "session-1" }, +}; +const tab = { + id: "tab-1", + createdAt: 0, + activePaneId: "pane-1", + layout: { type: "pane", paneId: "pane-1" } as const, + panes: { + "pane-1": terminalPane, + "pane-2": secondTerminalPane, + "pane-3": chatPane, + }, +}; + +describe("v2 notification store", () => { + beforeEach(() => { + useV2NotificationStore.setState({ sources: {} }); + }); + + it("maps panes and tabs to notification source ids", () => { + expect(getV2NotificationSourceIdsForPane(terminalPane)).toEqual([ + "terminal-1", + ]); + expect(getV2NotificationSourceIdsForPane(chatPane)).toEqual([]); + expect(getV2NotificationSourceIdsForTab(tab)).toEqual([ + "terminal-1", + "terminal-2", + ]); + }); + + it("derives workspace, tab, pane, and terminal status from terminal sources", () => { + const store = useV2NotificationStore.getState(); + store.setTerminalStatus("terminal-1", "workspace-1", "working", 100); + store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); + store.setTerminalStatus("terminal-3", "workspace-2", "review", 102); + + const state = useV2NotificationStore.getState(); + expect(selectV2WorkspaceNotificationStatus("workspace-1")(state)).toBe( + "permission", + ); + expect(selectV2TabNotificationStatus("workspace-1", tab)(state)).toBe( + "permission", + ); + expect( + selectV2PaneNotificationStatus("workspace-1", terminalPane)(state), + ).toBe("working"); + expect( + selectV2TerminalNotificationStatus("workspace-1", "terminal-2")(state), + ).toBe("permission"); + expect( + selectV2TerminalNotificationStatus("workspace-1", "terminal-3")(state), + ).toBeNull(); + }); + + it("clears only review attention for a source", () => { + const store = useV2NotificationStore.getState(); + store.setTerminalStatus("terminal-1", "workspace-1", "review", 100); + store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); + + store.clearSourceAttention("terminal-1", "workspace-1"); + store.clearSourceAttention("terminal-2", "workspace-1"); + + const state = useV2NotificationStore.getState(); + expect(state.sources["terminal-1"]).toBeUndefined(); + expect(state.sources["terminal-2"]?.status).toBe("permission"); + }); +}); diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.ts new file mode 100644 index 00000000000..27c5f923171 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.ts @@ -0,0 +1,249 @@ +import type { Pane, Tab } from "@superset/panes"; +import { + type ActivePaneStatus, + getHighestPriorityStatus, +} from "shared/tabs-types"; +import { create } from "zustand"; + +export type V2NotificationPaneLike = Pick, "kind" | "data">; +export type V2NotificationTabLike = Pick, "panes">; + +export interface V2NotificationSource { + sourceId: string; + terminalId: string; + workspaceId: string; + status: ActivePaneStatus; + occurredAt: number; +} + +export interface V2NotificationState { + sources: Record; + setTerminalStatus: ( + terminalId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + clearSourceStatus: (sourceId: string, workspaceId?: string) => void; + clearSourceStatuses: ( + sourceIds: Iterable, + workspaceId?: string, + ) => void; + clearSourceAttention: (sourceId: string, workspaceId?: string) => void; + clearWorkspaceStatuses: (workspaceId: string) => void; + clearWorkspaceAttention: (workspaceId: string) => void; +} + +export const useV2NotificationStore = create()((set) => ({ + sources: {}, + setTerminalStatus: ( + terminalId, + workspaceId, + status, + occurredAt = Date.now(), + ) => { + set((state) => ({ + sources: { + ...state.sources, + [terminalId]: { + sourceId: terminalId, + terminalId, + workspaceId, + status, + occurredAt, + }, + }, + })); + }, + clearSourceStatus: (sourceId, workspaceId) => { + set((state) => { + const source = state.sources[sourceId]; + if (!source || (workspaceId && source.workspaceId !== workspaceId)) { + return state; + } + const { [sourceId]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearSourceStatuses: (sourceIds, workspaceId) => { + set((state) => { + const ids = new Set(sourceIds); + const sources: Record = {}; + let changed = false; + for (const [sourceId, source] of Object.entries(state.sources)) { + if ( + ids.has(sourceId) && + (!workspaceId || source.workspaceId === workspaceId) + ) { + changed = true; + continue; + } + sources[sourceId] = source; + } + return changed ? { sources } : state; + }); + }, + clearSourceAttention: (sourceId, workspaceId) => { + set((state) => { + const source = state.sources[sourceId]; + if ( + !source || + source.status !== "review" || + (workspaceId && source.workspaceId !== workspaceId) + ) { + return state; + } + const { [sourceId]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearWorkspaceStatuses: (workspaceId) => { + set((state) => { + const sources: Record = {}; + let changed = false; + for (const [sourceId, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId) { + changed = true; + continue; + } + sources[sourceId] = source; + } + return changed ? { sources } : state; + }); + }, + clearWorkspaceAttention: (workspaceId) => { + set((state) => { + const sources: Record = {}; + let changed = false; + for (const [sourceId, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId && source.status === "review") { + changed = true; + continue; + } + sources[sourceId] = source; + } + return changed ? { sources } : state; + }); + }, +})); + +export function getV2NotificationSourceIdsForPane( + pane: V2NotificationPaneLike | null | undefined, +): string[] { + const terminalId = getTerminalIdForPane(pane); + return terminalId ? [terminalId] : []; +} + +export function getV2NotificationSourceIdsForTab( + tab: V2NotificationTabLike | null | undefined, +): string[] { + if (!tab) return []; + const sourceIds = new Set(); + for (const pane of Object.values(tab.panes)) { + for (const sourceId of getV2NotificationSourceIdsForPane(pane)) { + sourceIds.add(sourceId); + } + } + return [...sourceIds]; +} + +export function selectV2WorkspaceNotificationStatus(workspaceId: string) { + return (state: V2NotificationState) => { + function* statuses() { + for (const source of Object.values(state.sources)) { + if (source.workspaceId === workspaceId) { + yield source.status; + } + } + } + return getHighestPriorityStatus(statuses()); + }; +} + +export function selectV2TabNotificationStatus( + workspaceId: string, + tab: V2NotificationTabLike | null | undefined, +) { + const sourceIds = getV2NotificationSourceIdsForTab(tab); + return (state: V2NotificationState) => + selectStatusForSourceIds(state, workspaceId, sourceIds); +} + +export function selectV2PaneNotificationStatus( + workspaceId: string, + pane: V2NotificationPaneLike | null | undefined, +) { + const sourceIds = getV2NotificationSourceIdsForPane(pane); + return (state: V2NotificationState) => + selectStatusForSourceIds(state, workspaceId, sourceIds); +} + +export function selectV2TerminalNotificationStatus( + workspaceId: string, + terminalId: string | null | undefined, +) { + return (state: V2NotificationState) => + selectStatusForSourceIds( + state, + workspaceId, + terminalId ? [terminalId] : [], + ); +} + +export function useV2WorkspaceNotificationStatus(workspaceId: string) { + return useV2NotificationStore( + selectV2WorkspaceNotificationStatus(workspaceId), + ); +} + +export function useV2TabNotificationStatus( + workspaceId: string, + tab: V2NotificationTabLike | null | undefined, +) { + return useV2NotificationStore( + selectV2TabNotificationStatus(workspaceId, tab), + ); +} + +export function useV2PaneNotificationStatus( + workspaceId: string, + pane: V2NotificationPaneLike | null | undefined, +) { + return useV2NotificationStore( + selectV2PaneNotificationStatus(workspaceId, pane), + ); +} + +export function useV2TerminalNotificationStatus( + workspaceId: string, + terminalId: string | null | undefined, +) { + return useV2NotificationStore( + selectV2TerminalNotificationStatus(workspaceId, terminalId), + ); +} + +function selectStatusForSourceIds( + state: V2NotificationState, + workspaceId: string, + sourceIds: Iterable, +) { + function* statuses() { + for (const sourceId of sourceIds) { + const source = state.sources[sourceId]; + if (source?.workspaceId === workspaceId) { + yield source.status; + } + } + } + return getHighestPriorityStatus(statuses()); +} + +function getTerminalIdForPane( + pane: V2NotificationPaneLike | null | undefined, +): string | null { + if (!pane || pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const terminalId = (pane.data as { terminalId?: unknown }).terminalId; + return typeof terminalId === "string" && terminalId ? terminalId : null; +} diff --git a/apps/desktop/src/renderer/stores/v2-pane-status/index.ts b/apps/desktop/src/renderer/stores/v2-pane-status/index.ts deleted file mode 100644 index 53556b42468..00000000000 --- a/apps/desktop/src/renderer/stores/v2-pane-status/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - selectWorkspaceStatus, - useV2PaneStatusStore, -} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-pane-status/store.ts b/apps/desktop/src/renderer/stores/v2-pane-status/store.ts deleted file mode 100644 index f7af997c6ab..00000000000 --- a/apps/desktop/src/renderer/stores/v2-pane-status/store.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { getHighestPriorityStatus, type PaneStatus } from "shared/tabs-types"; -import { create } from "zustand"; - -/** - * Per-pane status for v2 panes. V2 panes live in the `@superset/panes` - * workspace-scoped store and have no `status` field on them; this store - * parallels that layout data with agent-lifecycle state so sidebar icons - * and tab chrome can indicate working/permission/review per pane and per - * workspace. - * - * Separate from the v1 `useTabsStore` because v2 paneIds aren't registered - * there — v2's derivation has to iterate this store directly and filter - * by workspaceId. - */ - -interface PaneStatusEntry { - workspaceId: string; - status: PaneStatus; -} - -interface V2PaneStatusState { - statuses: Record; - setPaneStatus: ( - paneId: string, - workspaceId: string, - status: PaneStatus, - ) => void; - clearPaneStatus: (paneId: string) => void; - clearWorkspaceStatuses: (workspaceId: string) => void; - /** - * Clear post-completion attention statuses (review) for a workspace. - * Mirrors v1's `resetWorkspaceStatus` — called when the user navigates - * into the workspace, since they're now looking at it and don't need - * the sidebar indicator anymore. Leaves `working` and `permission` - * untouched because those are still-active states. - */ - clearWorkspaceAttention: (workspaceId: string) => void; -} - -export const useV2PaneStatusStore = create()((set) => ({ - statuses: {}, - setPaneStatus: (paneId, workspaceId, status) => { - set((state) => ({ - statuses: { - ...state.statuses, - [paneId]: { workspaceId, status }, - }, - })); - }, - clearPaneStatus: (paneId) => { - set((state) => { - if (!state.statuses[paneId]) return state; - const { [paneId]: _removed, ...rest } = state.statuses; - return { statuses: rest }; - }); - }, - clearWorkspaceStatuses: (workspaceId) => { - set((state) => { - const next: Record = {}; - for (const [paneId, entry] of Object.entries(state.statuses)) { - if (entry.workspaceId !== workspaceId) { - next[paneId] = entry; - } - } - return { statuses: next }; - }); - }, - clearWorkspaceAttention: (workspaceId) => { - set((state) => { - const next: Record = {}; - let changed = false; - for (const [paneId, entry] of Object.entries(state.statuses)) { - if (entry.workspaceId === workspaceId && entry.status === "review") { - changed = true; - continue; - } - next[paneId] = entry; - } - return changed ? { statuses: next } : state; - }); - }, -})); - -/** - * Derive the highest-priority active status across all panes in a - * workspace. Returns null when every pane is idle — matches the v1 - * `WorkspaceListItem` derivation shape. - */ -export function selectWorkspaceStatus(workspaceId: string) { - return (state: V2PaneStatusState) => { - function* paneStatuses() { - for (const entry of Object.values(state.statuses)) { - if (entry.workspaceId === workspaceId) { - yield entry.status; - } - } - } - return getHighestPriorityStatus(paneStatuses()); - }; -} diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index 73c4c7f152f..170ecdcd208 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -78,7 +78,7 @@ agent shell hook POST /trpc/notifications.hook host-service maps event type host-service broadcasts agent:lifecycle over /events WebSocket - renderer listener updates v2 pane-status store + renderer listener updates v2 notification store renderer suppresses or plays ringtone renderer shows browser/OS Notification dashboard sidebar reads aggregated v2 status @@ -102,7 +102,7 @@ Important shipped pieces: - mounts listeners for v2 workspaces - `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener` - updates status, suppresses, plays sound, and shows notifications -- `apps/desktop/src/renderer/stores/v2-pane-status` +- `apps/desktop/src/renderer/stores/v2-notifications` - separate v2 status store, aggregated by workspace for the dashboard sidebar - `apps/desktop/src/renderer/lib/ringtones` - renderer-side built-in ringtone playback @@ -501,7 +501,7 @@ Manual QA: ### Phase 2: Refactor Ownership - Replace per-workspace listeners with per-host notification controllers. -- Rename `v2-pane-status` or wrap it as an agent attention store keyed by source key, not fake pane ID. +- Store v2 notification attention in `v2-notifications` keyed by source key, not fake pane ID. - Add a pane-layout identity index for v2. - Introduce a `NotificationPlatform` abstraction for preferences, playback, browser/OS notification, and focus. - Keep desktop implementation backed by existing Electron/local-db APIs. From e3fcd2d5adb748b518910ea9b8257dfd98f445e4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 17:20:30 -0700 Subject: [PATCH 11/17] feat(desktop): show v2 notification status on tabs and panes --- .../V2NotificationStatusIndicator.tsx | 18 +++++++++++ .../V2NotificationStatusIndicator/index.ts | 1 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 19 +++++++++++ .../v2-workspace/$workspaceId/page.tsx | 8 +++++ .../renderer/stores/v2-notifications/index.ts | 2 ++ .../stores/v2-notifications/store.test.ts | 7 ++++ .../renderer/stores/v2-notifications/store.ts | 32 +++++++++++++------ 7 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx new file mode 100644 index 00000000000..e9fe8e877de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx @@ -0,0 +1,18 @@ +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { useV2SourceIdsNotificationStatus } from "renderer/stores/v2-notifications"; + +interface V2NotificationStatusIndicatorProps { + workspaceId: string; + sourceIds: Iterable; + className?: string; +} + +export function V2NotificationStatusIndicator({ + workspaceId, + sourceIds, + className, +}: V2NotificationStatusIndicatorProps) { + const status = useV2SourceIdsNotificationStatus(workspaceId, sourceIds); + if (!status) return null; + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts new file mode 100644 index 00000000000..8f354b0d419 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts @@ -0,0 +1 @@ +export { V2NotificationStatusIndicator } from "./V2NotificationStatusIndicator"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4dd3195bc33..10ce69cdb1f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -28,6 +28,8 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { useSettings } from "renderer/stores/settings"; +import { getV2NotificationSourceIdsForPane } from "renderer/stores/v2-notifications"; +import { V2NotificationStatusIndicator } from "../../components/V2NotificationStatusIndicator"; import { getDocument, useSharedFileDocument, @@ -236,6 +238,23 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", + renderTitle: (ctx: RendererContext) => ( + <> + + + Terminal + + + + ), renderPane: (ctx: RendererContext) => ( ( + + )} renderBelowTabBar={() => ( { expect( selectV2TerminalNotificationStatus("workspace-1", "terminal-2")(state), ).toBe("permission"); + expect( + selectV2SourceIdsNotificationStatus("workspace-1", [ + "terminal-1", + "terminal-2", + ])(state), + ).toBe("permission"); expect( selectV2TerminalNotificationStatus("workspace-1", "terminal-3")(state), ).toBeNull(); diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.ts index 27c5f923171..20890220906 100644 --- a/apps/desktop/src/renderer/stores/v2-notifications/store.ts +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.ts @@ -165,8 +165,7 @@ export function selectV2TabNotificationStatus( tab: V2NotificationTabLike | null | undefined, ) { const sourceIds = getV2NotificationSourceIdsForTab(tab); - return (state: V2NotificationState) => - selectStatusForSourceIds(state, workspaceId, sourceIds); + return selectV2SourceIdsNotificationStatus(workspaceId, sourceIds); } export function selectV2PaneNotificationStatus( @@ -174,20 +173,26 @@ export function selectV2PaneNotificationStatus( pane: V2NotificationPaneLike | null | undefined, ) { const sourceIds = getV2NotificationSourceIdsForPane(pane); - return (state: V2NotificationState) => - selectStatusForSourceIds(state, workspaceId, sourceIds); + return selectV2SourceIdsNotificationStatus(workspaceId, sourceIds); } export function selectV2TerminalNotificationStatus( workspaceId: string, terminalId: string | null | undefined, ) { + return selectV2SourceIdsNotificationStatus( + workspaceId, + terminalId ? [terminalId] : [], + ); +} + +export function selectV2SourceIdsNotificationStatus( + workspaceId: string, + sourceIds: Iterable, +) { + const sourceIdList = [...new Set(sourceIds)]; return (state: V2NotificationState) => - selectStatusForSourceIds( - state, - workspaceId, - terminalId ? [terminalId] : [], - ); + selectStatusForSourceIds(state, workspaceId, sourceIdList); } export function useV2WorkspaceNotificationStatus(workspaceId: string) { @@ -223,6 +228,15 @@ export function useV2TerminalNotificationStatus( ); } +export function useV2SourceIdsNotificationStatus( + workspaceId: string, + sourceIds: Iterable, +) { + return useV2NotificationStore( + selectV2SourceIdsNotificationStatus(workspaceId, sourceIds), + ); +} + function selectStatusForSourceIds( state: V2NotificationState, workspaceId: string, From 08a195f2a9e4d06fe9074b4e25454b5def0c3f32 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 17:56:24 -0700 Subject: [PATCH 12/17] refactor(desktop): type v2 notification sources --- .../V2NotificationStatusIndicator.tsx | 11 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 21 +- .../resolveV2NotificationTarget.test.ts | 2 +- .../resolveV2NotificationTarget.ts | 6 - .../statusTransitions.test.ts | 52 ++-- .../statusTransitions.ts | 44 ++-- .../useV2AgentHookListener.ts | 42 ++-- .../v2-workspace/$workspaceId/page.tsx | 10 +- .../components/HostListener/index.ts | 2 - .../components/V2AgentHookListeners/index.ts | 1 - .../V2NotificationController.tsx} | 20 +- .../HostNotificationSubscriber.tsx} | 7 +- .../HostNotificationSubscriber/index.ts | 2 + .../V2NotificationController/index.ts | 1 + .../renderer/routes/_authenticated/layout.tsx | 4 +- .../renderer/stores/v2-notifications/index.ts | 17 +- .../stores/v2-notifications/store.test.ts | 53 ++-- .../renderer/stores/v2-notifications/store.ts | 230 +++++++++++++----- ...60422-v2-notification-hooks-client-side.md | 24 +- 19 files changed, 345 insertions(+), 204 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts rename apps/desktop/src/renderer/routes/_authenticated/components/{V2AgentHookListeners/V2AgentHookListeners.tsx => V2NotificationController/V2NotificationController.tsx} (88%) rename apps/desktop/src/renderer/routes/_authenticated/components/{V2AgentHookListeners/components/HostListener/HostListener.tsx => V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx} (93%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx index e9fe8e877de..55aa9879e9b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx @@ -1,18 +1,21 @@ import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; -import { useV2SourceIdsNotificationStatus } from "renderer/stores/v2-notifications"; +import { + useV2SourcesNotificationStatus, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; interface V2NotificationStatusIndicatorProps { workspaceId: string; - sourceIds: Iterable; + sources: Iterable; className?: string; } export function V2NotificationStatusIndicator({ workspaceId, - sourceIds, + sources, className, }: V2NotificationStatusIndicatorProps) { - const status = useV2SourceIdsNotificationStatus(workspaceId, sourceIds); + const status = useV2SourcesNotificationStatus(workspaceId, sources); if (!status) return null; return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 10ce69cdb1f..a69587105be 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -28,7 +28,7 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { useSettings } from "renderer/stores/settings"; -import { getV2NotificationSourceIdsForPane } from "renderer/stores/v2-notifications"; +import { getV2NotificationSourcesForPane } from "renderer/stores/v2-notifications"; import { V2NotificationStatusIndicator } from "../../components/V2NotificationStatusIndicator"; import { getDocument, @@ -251,7 +251,7 @@ export function usePaneRegistry( ), @@ -362,6 +362,23 @@ export function usePaneRegistry( chat: { getIcon: () => , getTitle: () => "Chat", + renderTitle: (ctx: RendererContext) => ( + <> + + + Chat + + + + ), // Disabled until ChatServiceProvider is wired above v2 panes — // TiptapPromptEditor needs its tRPC context. renderPane: (_ctx: RendererContext) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts index 52ecb62b06b..bf785075ca2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts @@ -120,7 +120,7 @@ describe("resolveV2NotificationTarget", () => { ).toBe(false); }); - it("uses the terminal id as the status key", () => { + it("uses the terminal id as the native notification source id", () => { expect(getNotificationSourceId(payload({ terminalId: "terminal-1" }))).toBe( "terminal-1", ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts index 3972d1d6468..08332c436d7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts @@ -15,12 +15,6 @@ export function getNotificationSourceId( return payload.terminalId; } -export function getNotificationSourceIds( - payload: Pick, -): string[] { - return [payload.terminalId]; -} - export function resolveV2NotificationTarget({ workspaceId, payload, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts index cc4f1760df6..c00073f6814 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import type { V2NotificationTarget } from "./resolveV2NotificationTarget"; import { resolveV2AgentStatusTransition } from "./statusTransitions"; const WORKSPACE_ID = "workspace-1"; -const target: V2NotificationTarget = { - workspaceId: WORKSPACE_ID, - tabId: "tab-1", - paneId: "pane-1", - terminalId: "terminal-1", -}; - function payload( overrides: Partial, ): AgentLifecyclePayload { @@ -24,7 +16,7 @@ function payload( } describe("resolveV2AgentStatusTransition", () => { - it("marks start as working on the terminal id and clears pane aliases", () => { + it("marks start as working on the terminal source", () => { expect( resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, @@ -32,17 +24,19 @@ describe("resolveV2AgentStatusTransition", () => { eventType: "Start", terminalId: "terminal-1", }), - target, statuses: {}, targetVisible: false, }), ).toEqual({ - clearIds: ["pane-1"], - setStatus: { id: "terminal-1", status: "working" }, + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "working", + }, }); }); - it("clears permission state on stop even when permission was keyed by the pane id", () => { + it("clears permission state on stop", () => { expect( resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, @@ -50,14 +44,16 @@ describe("resolveV2AgentStatusTransition", () => { eventType: "Stop", terminalId: "terminal-1", }), - target, statuses: { - "pane-1": { workspaceId: WORKSPACE_ID, status: "permission" }, + "terminal:terminal-1": { + workspaceId: WORKSPACE_ID, + status: "permission", + }, }, targetVisible: false, }), ).toEqual({ - clearIds: ["terminal-1", "pane-1"], + clearSources: [{ type: "terminal", id: "terminal-1" }], setStatus: null, }); }); @@ -67,12 +63,11 @@ describe("resolveV2AgentStatusTransition", () => { resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), - target, statuses: {}, targetVisible: true, }), ).toEqual({ - clearIds: ["terminal-1", "pane-1"], + clearSources: [{ type: "terminal", id: "terminal-1" }], setStatus: null, }); }); @@ -82,13 +77,15 @@ describe("resolveV2AgentStatusTransition", () => { resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), - target, statuses: {}, targetVisible: false, }), ).toEqual({ - clearIds: ["pane-1"], - setStatus: { id: "terminal-1", status: "review" }, + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "review", + }, }); }); @@ -97,15 +94,20 @@ describe("resolveV2AgentStatusTransition", () => { resolveV2AgentStatusTransition({ workspaceId: WORKSPACE_ID, payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), - target, statuses: { - "terminal-1": { workspaceId: "workspace-2", status: "permission" }, + "terminal:terminal-1": { + workspaceId: "workspace-2", + status: "permission", + }, }, targetVisible: false, }), ).toEqual({ - clearIds: ["pane-1"], - setStatus: { id: "terminal-1", status: "review" }, + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "review", + }, }); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts index 7dc9385af18..d6c9d614947 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts @@ -1,7 +1,11 @@ import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { + getV2NotificationSourceKey, + getV2TerminalNotificationSource, + type V2NotificationSource, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; import type { ActivePaneStatus, PaneStatus } from "shared/tabs-types"; -import type { V2NotificationTarget } from "./resolveV2NotificationTarget"; -import { getNotificationSourceIds } from "./resolveV2NotificationTarget"; interface StatusEntry { workspaceId: string; @@ -9,55 +13,47 @@ interface StatusEntry { } export interface V2AgentStatusTransition { - clearIds: string[]; - setStatus: { id: string; status: ActivePaneStatus } | null; + clearSources: V2NotificationSourceInput[]; + setStatus: { source: V2NotificationSource; status: ActivePaneStatus } | null; } export function resolveV2AgentStatusTransition({ workspaceId, payload, - target, statuses, targetVisible, }: { workspaceId: string; payload: AgentLifecyclePayload; - target: V2NotificationTarget; statuses: Record; targetVisible: boolean; }): V2AgentStatusTransition { - const statusIds = new Set(getNotificationSourceIds(payload)); - if (target.paneId) statusIds.add(target.paneId); - - const primaryId = target.terminalId; - statusIds.add(primaryId); - const alternateIds = [...statusIds].filter((id) => id !== primaryId); + const terminalSource = getV2TerminalNotificationSource(payload.terminalId); + const terminalSourceKey = getV2NotificationSourceKey(terminalSource); if (payload.eventType === "Start") { return { - clearIds: alternateIds, - setStatus: { id: primaryId, status: "working" }, + clearSources: [], + setStatus: { source: terminalSource, status: "working" }, }; } if (payload.eventType === "PermissionRequest") { return { - clearIds: alternateIds, - setStatus: { id: primaryId, status: "permission" }, + clearSources: [], + setStatus: { source: terminalSource, status: "permission" }, }; } - const allIds = [primaryId, ...alternateIds]; - const wasAwaitingPermission = allIds.some((id) => { - const entry = statuses[id]; - return entry?.workspaceId === workspaceId && entry.status === "permission"; - }); + const entry = statuses[terminalSourceKey]; + const wasAwaitingPermission = + entry?.workspaceId === workspaceId && entry.status === "permission"; if (wasAwaitingPermission || targetVisible) { - return { clearIds: allIds, setStatus: null }; + return { clearSources: [terminalSource], setStatus: null }; } return { - clearIds: alternateIds, - setStatus: { id: primaryId, status: "review" }, + clearSources: [], + setStatus: { source: terminalSource, status: "review" }, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 2a30d9e38df..57dcd5f130d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -12,12 +12,15 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useRingtoneStore } from "renderer/stores/ringtone"; -import { useV2NotificationStore } from "renderer/stores/v2-notifications"; +import { + getV2TerminalNotificationSource, + useV2NotificationStore, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; import type { PaneViewerData } from "../../types"; import { getNotificationSourceId, isV2NotificationTargetVisible, - resolveTerminalTarget, resolveV2NotificationTarget, type V2NotificationTarget, } from "./resolveV2NotificationTarget"; @@ -37,7 +40,7 @@ type Navigate = ReturnType; * pane is visible and the window is focused, and honor the existing * mute/volume settings. * - * The layout-level `V2AgentHookListeners` component is the active mount path: + * The layout-level `V2NotificationController` component is the active mount path: * it subscribes once per host so backgrounded workspaces also light up the * sidebar. */ @@ -84,10 +87,9 @@ export function useV2AgentHookListener(workspaceId: string): void { handleV2TerminalLifecycleEvent({ workspaceId, payload, - paneLayout, }); }, - [workspaceId, paneLayout], + [workspaceId], ); useWorkspaceEvent("agent:lifecycle", workspaceId, handleEvent); @@ -130,19 +132,14 @@ export function handleV2AgentLifecycleEvent({ export function handleV2TerminalLifecycleEvent({ workspaceId, payload, - paneLayout, }: { workspaceId: string; payload: TerminalLifecyclePayload; - paneLayout: WorkspaceState | null | undefined; }): void { if (payload.eventType !== "exit") return; - const target = resolveTerminalTarget({ - workspaceId, - terminalId: payload.terminalId, - paneLayout, - }); - clearStatusIds(workspaceId, [payload.terminalId, target?.paneId]); + clearSources(workspaceId, [ + getV2TerminalNotificationSource(payload.terminalId), + ]); } /** @@ -169,15 +166,14 @@ function updatePaneStatus( const transition = resolveV2AgentStatusTransition({ workspaceId, payload, - target, statuses: store.sources, targetVisible, }); - clearStatusIds(workspaceId, transition.clearIds); + clearSources(workspaceId, transition.clearSources); if (transition.setStatus) { - store.setTerminalStatus( - transition.setStatus.id, + store.setSourceStatus( + transition.setStatus.source, workspaceId, transition.setStatus.status, payload.occurredAt, @@ -244,13 +240,17 @@ function showNativeNotification( } } -function clearStatusIds( +function clearSources( workspaceId: string, - ids: Array, + sources: Array, ): void { const store = useV2NotificationStore.getState(); - const uniqueIds = new Set(ids.filter((id): id is string => Boolean(id))); - store.clearSourceStatuses(uniqueIds, workspaceId); + store.clearSourceStatuses( + sources.filter((source): source is V2NotificationSourceInput => + Boolean(source), + ), + workspaceId, + ); } function openNotificationTarget( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index a8d26fa2ece..fe4913db727 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -21,8 +21,8 @@ import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { - getV2NotificationSourceIdsForPane, - getV2NotificationSourceIdsForTab, + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, useV2NotificationStore, useV2PaneNotificationStatus, } from "renderer/stores/v2-notifications"; @@ -135,8 +135,8 @@ function useClearActivePaneAttention({ useEffect(() => { if (activePaneStatus !== "review") return; - for (const sourceId of getV2NotificationSourceIdsForPane(activePane)) { - clearSourceAttention(sourceId, workspaceId); + for (const source of getV2NotificationSourcesForPane(activePane)) { + clearSourceAttention(source, workspaceId); } }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); } @@ -451,7 +451,7 @@ function WorkspaceContent({ renderTabAccessory={(tab) => ( )} renderBelowTabBar={() => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts deleted file mode 100644 index 1ec4b626704..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { HostWorkspaceListenerState } from "./HostListener"; -export { HostListener } from "./HostListener"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts deleted file mode 100644 index 6f922eb8d6d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { V2AgentHookListeners } from "./V2AgentHookListeners"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx similarity index 88% rename from apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx index 67d23bcabab..5a5f21f9219 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/V2AgentHookListeners.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx @@ -7,9 +7,9 @@ import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { - HostListener, - type HostWorkspaceListenerState, -} from "./components/HostListener"; + HostNotificationSubscriber, + type HostNotificationWorkspaceState, +} from "./components/HostNotificationSubscriber"; interface WorkspaceHostRow { workspaceId: string; @@ -17,9 +17,9 @@ interface WorkspaceHostRow { hostMachineId: string | null | undefined; } -interface HostListenerGroup { +interface HostNotificationSubscriberGroup { hostUrl: string; - workspaces: HostWorkspaceListenerState[]; + workspaces: HostNotificationWorkspaceState[]; } /** @@ -27,11 +27,11 @@ interface HostListenerGroup { * workspaces update their sidebar status indicator and play the finish sound. * Sibling to `AgentHooks`; rendered at the authenticated layout level. * - * A host listener subscribes with workspaceId `*` and filters against the + * A host subscriber subscribes with workspaceId `*` and filters against the * workspaces assigned to that host. This keeps the topology O(1 listener per * host), not O(1 listener and settings observer per workspace). */ -export function V2AgentHookListeners() { +export function V2NotificationController() { const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); const { data: workspaceHosts = [] } = useLiveQuery( @@ -73,7 +73,7 @@ export function V2AgentHookListeners() { return ( <> {hostGroups.map((group) => ( - ; machineId: string | null; activeHostUrl: string | null; -}): HostListenerGroup[] { +}): HostNotificationSubscriberGroup[] { const paneLayoutsByWorkspaceId = new Map( localWorkspaceRows.map((row) => [ row.workspaceId, row.paneLayout as WorkspaceState, ]), ); - const groups = new Map(); + const groups = new Map(); for (const workspace of workspaceHosts) { const hostUrl = getHostUrlForWorkspace({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx similarity index 93% rename from apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx index b1d2036d15a..36c9b068aeb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners/components/HostListener/HostListener.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx @@ -14,17 +14,17 @@ import { } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; -export interface HostWorkspaceListenerState { +export interface HostNotificationWorkspaceState { workspaceId: string; paneLayout: WorkspaceState | null; } -export function HostListener({ +export function HostNotificationSubscriber({ hostUrl, workspaces, }: { hostUrl: string; - workspaces: HostWorkspaceListenerState[]; + workspaces: HostNotificationWorkspaceState[]; }): null { const navigate = useNavigate(); const { data: volume = 100 } = @@ -61,7 +61,6 @@ export function HostListener({ handleV2TerminalLifecycleEvent({ workspaceId, payload, - paneLayout: workspace.paneLayout, }); }, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts new file mode 100644 index 00000000000..f765167566a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts @@ -0,0 +1,2 @@ +export type { HostNotificationWorkspaceState } from "./HostNotificationSubscriber"; +export { HostNotificationSubscriber } from "./HostNotificationSubscriber"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts new file mode 100644 index 00000000000..53c2fd5468a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts @@ -0,0 +1 @@ +export { V2NotificationController } from "./V2NotificationController"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index d9b5cbb2774..c5d3f4d00ce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -36,7 +36,7 @@ import { AgentHooks } from "./components/AgentHooks"; import { GlobalBrowserLifecycle } from "./components/GlobalBrowserLifecycle"; import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; -import { V2AgentHookListeners } from "./components/V2AgentHookListeners"; +import { V2NotificationController } from "./components/V2NotificationController"; import { createPierreWorker } from "./lib/pierreWorker"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { DeletingWorkspacesProvider } from "./providers/DeletingWorkspacesProvider"; @@ -200,7 +200,7 @@ function AuthenticatedLayout() { highlighterOptions={{ preferredHighlighter: "shiki-wasm" }} > - + {isV2CloudEnabled ? ( diff --git a/apps/desktop/src/renderer/stores/v2-notifications/index.ts b/apps/desktop/src/renderer/stores/v2-notifications/index.ts index fb5af86f9a7..bc268226195 100644 --- a/apps/desktop/src/renderer/stores/v2-notifications/index.ts +++ b/apps/desktop/src/renderer/stores/v2-notifications/index.ts @@ -1,19 +1,28 @@ export { - getV2NotificationSourceIdsForPane, - getV2NotificationSourceIdsForTab, + getV2ChatNotificationSource, + getV2NotificationSourceKey, + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + getV2TerminalNotificationSource, + selectV2ChatNotificationStatus, selectV2PaneNotificationStatus, - selectV2SourceIdsNotificationStatus, + selectV2SourcesNotificationStatus, selectV2TabNotificationStatus, selectV2TerminalNotificationStatus, selectV2WorkspaceNotificationStatus, + useV2ChatNotificationStatus, useV2NotificationStore, useV2PaneNotificationStatus, - useV2SourceIdsNotificationStatus, + useV2SourcesNotificationStatus, useV2TabNotificationStatus, useV2TerminalNotificationStatus, useV2WorkspaceNotificationStatus, type V2NotificationPaneLike, type V2NotificationSource, + type V2NotificationSourceInput, + type V2NotificationSourceKey, + type V2NotificationSourceType, type V2NotificationState, + type V2NotificationStatusEntry, type V2NotificationTabLike, } from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts index 7d593954f5a..56fbbbec0ec 100644 --- a/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { - getV2NotificationSourceIdsForPane, - getV2NotificationSourceIdsForTab, + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + selectV2ChatNotificationStatus, selectV2PaneNotificationStatus, - selectV2SourceIdsNotificationStatus, + selectV2SourcesNotificationStatus, selectV2TabNotificationStatus, selectV2TerminalNotificationStatus, selectV2WorkspaceNotificationStatus, @@ -42,22 +43,26 @@ describe("v2 notification store", () => { useV2NotificationStore.setState({ sources: {} }); }); - it("maps panes and tabs to notification source ids", () => { - expect(getV2NotificationSourceIdsForPane(terminalPane)).toEqual([ - "terminal-1", + it("maps panes and tabs to typed notification sources", () => { + expect(getV2NotificationSourcesForPane(terminalPane)).toEqual([ + { type: "terminal", id: "terminal-1" }, ]); - expect(getV2NotificationSourceIdsForPane(chatPane)).toEqual([]); - expect(getV2NotificationSourceIdsForTab(tab)).toEqual([ - "terminal-1", - "terminal-2", + expect(getV2NotificationSourcesForPane(chatPane)).toEqual([ + { type: "chat", id: "session-1" }, + ]); + expect(getV2NotificationSourcesForTab(tab)).toEqual([ + { type: "terminal", id: "terminal-1" }, + { type: "terminal", id: "terminal-2" }, + { type: "chat", id: "session-1" }, ]); }); - it("derives workspace, tab, pane, and terminal status from terminal sources", () => { + it("derives workspace, tab, pane, terminal, and chat status from sources", () => { const store = useV2NotificationStore.getState(); store.setTerminalStatus("terminal-1", "workspace-1", "working", 100); store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); store.setTerminalStatus("terminal-3", "workspace-2", "review", 102); + store.setChatStatus("session-1", "workspace-1", "review", 103); const state = useV2NotificationStore.getState(); expect(selectV2WorkspaceNotificationStatus("workspace-1")(state)).toBe( @@ -69,13 +74,19 @@ describe("v2 notification store", () => { expect( selectV2PaneNotificationStatus("workspace-1", terminalPane)(state), ).toBe("working"); + expect(selectV2PaneNotificationStatus("workspace-1", chatPane)(state)).toBe( + "review", + ); expect( selectV2TerminalNotificationStatus("workspace-1", "terminal-2")(state), ).toBe("permission"); expect( - selectV2SourceIdsNotificationStatus("workspace-1", [ - "terminal-1", - "terminal-2", + selectV2ChatNotificationStatus("workspace-1", "session-1")(state), + ).toBe("review"); + expect( + selectV2SourcesNotificationStatus("workspace-1", [ + { type: "terminal", id: "terminal-1" }, + { type: "terminal", id: "terminal-2" }, ])(state), ).toBe("permission"); expect( @@ -88,11 +99,17 @@ describe("v2 notification store", () => { store.setTerminalStatus("terminal-1", "workspace-1", "review", 100); store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); - store.clearSourceAttention("terminal-1", "workspace-1"); - store.clearSourceAttention("terminal-2", "workspace-1"); + store.clearSourceAttention( + { type: "terminal", id: "terminal-1" }, + "workspace-1", + ); + store.clearSourceAttention( + { type: "terminal", id: "terminal-2" }, + "workspace-1", + ); const state = useV2NotificationStore.getState(); - expect(state.sources["terminal-1"]).toBeUndefined(); - expect(state.sources["terminal-2"]?.status).toBe("permission"); + expect(state.sources["terminal:terminal-1"]).toBeUndefined(); + expect(state.sources["terminal:terminal-2"]?.status).toBe("permission"); }); }); diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.ts index 20890220906..288e3e80d26 100644 --- a/apps/desktop/src/renderer/stores/v2-notifications/store.ts +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.ts @@ -8,46 +8,70 @@ import { create } from "zustand"; export type V2NotificationPaneLike = Pick, "kind" | "data">; export type V2NotificationTabLike = Pick, "panes">; -export interface V2NotificationSource { - sourceId: string; - terminalId: string; +export type V2NotificationSource = + | { type: "terminal"; id: string } + | { type: "chat"; id: string }; + +export type V2NotificationSourceType = V2NotificationSource["type"]; +export type V2NotificationSourceKey = `${V2NotificationSourceType}:${string}`; +export type V2NotificationSourceInput = + | V2NotificationSource + | V2NotificationSourceKey; + +export interface V2NotificationStatusEntry { + sourceKey: V2NotificationSourceKey; + source: V2NotificationSource; workspaceId: string; status: ActivePaneStatus; occurredAt: number; } export interface V2NotificationState { - sources: Record; + sources: Record; + setSourceStatus: ( + source: V2NotificationSource, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; setTerminalStatus: ( terminalId: string, workspaceId: string, status: ActivePaneStatus, occurredAt?: number, ) => void; - clearSourceStatus: (sourceId: string, workspaceId?: string) => void; + setChatStatus: ( + chatId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + clearSourceStatus: ( + source: V2NotificationSourceInput, + workspaceId?: string, + ) => void; clearSourceStatuses: ( - sourceIds: Iterable, + sources: Iterable, + workspaceId?: string, + ) => void; + clearSourceAttention: ( + source: V2NotificationSourceInput, workspaceId?: string, ) => void; - clearSourceAttention: (sourceId: string, workspaceId?: string) => void; clearWorkspaceStatuses: (workspaceId: string) => void; clearWorkspaceAttention: (workspaceId: string) => void; } export const useV2NotificationStore = create()((set) => ({ sources: {}, - setTerminalStatus: ( - terminalId, - workspaceId, - status, - occurredAt = Date.now(), - ) => { + setSourceStatus: (source, workspaceId, status, occurredAt = Date.now()) => { + const sourceKey = getV2NotificationSourceKey(source); set((state) => ({ sources: { ...state.sources, - [terminalId]: { - sourceId: terminalId, - terminalId, + [sourceKey]: { + sourceKey, + source, workspaceId, status, occurredAt, @@ -55,96 +79,142 @@ export const useV2NotificationStore = create()((set) => ({ }, })); }, - clearSourceStatus: (sourceId, workspaceId) => { + setTerminalStatus: (terminalId, workspaceId, status, occurredAt) => { + useV2NotificationStore + .getState() + .setSourceStatus( + getV2TerminalNotificationSource(terminalId), + workspaceId, + status, + occurredAt, + ); + }, + setChatStatus: (chatId, workspaceId, status, occurredAt) => { + useV2NotificationStore + .getState() + .setSourceStatus( + getV2ChatNotificationSource(chatId), + workspaceId, + status, + occurredAt, + ); + }, + clearSourceStatus: (source, workspaceId) => { + const sourceKey = getV2NotificationSourceKey(source); set((state) => { - const source = state.sources[sourceId]; - if (!source || (workspaceId && source.workspaceId !== workspaceId)) { + const entry = state.sources[sourceKey]; + if (!entry || (workspaceId && entry.workspaceId !== workspaceId)) { return state; } - const { [sourceId]: _removed, ...sources } = state.sources; + const { [sourceKey]: _removed, ...sources } = state.sources; return { sources }; }); }, - clearSourceStatuses: (sourceIds, workspaceId) => { + clearSourceStatuses: (sourceInputs, workspaceId) => { set((state) => { - const ids = new Set(sourceIds); - const sources: Record = {}; + const sourceKeys = new Set( + [...sourceInputs].map(getV2NotificationSourceKey), + ); + const sources: Record = {}; let changed = false; - for (const [sourceId, source] of Object.entries(state.sources)) { + for (const [sourceKey, source] of Object.entries(state.sources)) { if ( - ids.has(sourceId) && + sourceKeys.has(sourceKey as V2NotificationSourceKey) && (!workspaceId || source.workspaceId === workspaceId) ) { changed = true; continue; } - sources[sourceId] = source; + sources[sourceKey] = source; } return changed ? { sources } : state; }); }, - clearSourceAttention: (sourceId, workspaceId) => { + clearSourceAttention: (source, workspaceId) => { + const sourceKey = getV2NotificationSourceKey(source); set((state) => { - const source = state.sources[sourceId]; + const entry = state.sources[sourceKey]; if ( - !source || - source.status !== "review" || - (workspaceId && source.workspaceId !== workspaceId) + !entry || + entry.status !== "review" || + (workspaceId && entry.workspaceId !== workspaceId) ) { return state; } - const { [sourceId]: _removed, ...sources } = state.sources; + const { [sourceKey]: _removed, ...sources } = state.sources; return { sources }; }); }, clearWorkspaceStatuses: (workspaceId) => { set((state) => { - const sources: Record = {}; + const sources: Record = {}; let changed = false; - for (const [sourceId, source] of Object.entries(state.sources)) { + for (const [sourceKey, source] of Object.entries(state.sources)) { if (source.workspaceId === workspaceId) { changed = true; continue; } - sources[sourceId] = source; + sources[sourceKey] = source; } return changed ? { sources } : state; }); }, clearWorkspaceAttention: (workspaceId) => { set((state) => { - const sources: Record = {}; + const sources: Record = {}; let changed = false; - for (const [sourceId, source] of Object.entries(state.sources)) { + for (const [sourceKey, source] of Object.entries(state.sources)) { if (source.workspaceId === workspaceId && source.status === "review") { changed = true; continue; } - sources[sourceId] = source; + sources[sourceKey] = source; } return changed ? { sources } : state; }); }, })); -export function getV2NotificationSourceIdsForPane( +export function getV2NotificationSourceKey( + source: V2NotificationSourceInput, +): V2NotificationSourceKey { + if (typeof source === "string") return source; + return `${source.type}:${source.id}`; +} + +export function getV2TerminalNotificationSource( + terminalId: string, +): V2NotificationSource { + return { type: "terminal", id: terminalId }; +} + +export function getV2ChatNotificationSource( + chatId: string, +): V2NotificationSource { + return { type: "chat", id: chatId }; +} + +export function getV2NotificationSourcesForPane( pane: V2NotificationPaneLike | null | undefined, -): string[] { +): V2NotificationSource[] { const terminalId = getTerminalIdForPane(pane); - return terminalId ? [terminalId] : []; + if (terminalId) return [getV2TerminalNotificationSource(terminalId)]; + const chatId = getChatIdForPane(pane); + if (chatId) return [getV2ChatNotificationSource(chatId)]; + return []; } -export function getV2NotificationSourceIdsForTab( +export function getV2NotificationSourcesForTab( tab: V2NotificationTabLike | null | undefined, -): string[] { +): V2NotificationSource[] { if (!tab) return []; - const sourceIds = new Set(); + const sources = new Map(); for (const pane of Object.values(tab.panes)) { - for (const sourceId of getV2NotificationSourceIdsForPane(pane)) { - sourceIds.add(sourceId); + for (const source of getV2NotificationSourcesForPane(pane)) { + sources.set(getV2NotificationSourceKey(source), source); } } - return [...sourceIds]; + return [...sources.values()]; } export function selectV2WorkspaceNotificationStatus(workspaceId: string) { @@ -164,35 +234,49 @@ export function selectV2TabNotificationStatus( workspaceId: string, tab: V2NotificationTabLike | null | undefined, ) { - const sourceIds = getV2NotificationSourceIdsForTab(tab); - return selectV2SourceIdsNotificationStatus(workspaceId, sourceIds); + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForTab(tab), + ); } export function selectV2PaneNotificationStatus( workspaceId: string, pane: V2NotificationPaneLike | null | undefined, ) { - const sourceIds = getV2NotificationSourceIdsForPane(pane); - return selectV2SourceIdsNotificationStatus(workspaceId, sourceIds); + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForPane(pane), + ); } export function selectV2TerminalNotificationStatus( workspaceId: string, terminalId: string | null | undefined, ) { - return selectV2SourceIdsNotificationStatus( + return selectV2SourcesNotificationStatus( + workspaceId, + terminalId ? [getV2TerminalNotificationSource(terminalId)] : [], + ); +} + +export function selectV2ChatNotificationStatus( + workspaceId: string, + chatId: string | null | undefined, +) { + return selectV2SourcesNotificationStatus( workspaceId, - terminalId ? [terminalId] : [], + chatId ? [getV2ChatNotificationSource(chatId)] : [], ); } -export function selectV2SourceIdsNotificationStatus( +export function selectV2SourcesNotificationStatus( workspaceId: string, - sourceIds: Iterable, + sources: Iterable, ) { - const sourceIdList = [...new Set(sourceIds)]; + const sourceKeys = [...new Set([...sources].map(getV2NotificationSourceKey))]; return (state: V2NotificationState) => - selectStatusForSourceIds(state, workspaceId, sourceIdList); + selectStatusForSourceKeys(state, workspaceId, sourceKeys); } export function useV2WorkspaceNotificationStatus(workspaceId: string) { @@ -228,23 +312,32 @@ export function useV2TerminalNotificationStatus( ); } -export function useV2SourceIdsNotificationStatus( +export function useV2ChatNotificationStatus( workspaceId: string, - sourceIds: Iterable, + chatId: string | null | undefined, ) { return useV2NotificationStore( - selectV2SourceIdsNotificationStatus(workspaceId, sourceIds), + selectV2ChatNotificationStatus(workspaceId, chatId), ); } -function selectStatusForSourceIds( +export function useV2SourcesNotificationStatus( + workspaceId: string, + sources: Iterable, +) { + return useV2NotificationStore( + selectV2SourcesNotificationStatus(workspaceId, sources), + ); +} + +function selectStatusForSourceKeys( state: V2NotificationState, workspaceId: string, - sourceIds: Iterable, + sourceKeys: Iterable, ) { function* statuses() { - for (const sourceId of sourceIds) { - const source = state.sources[sourceId]; + for (const sourceKey of sourceKeys) { + const source = state.sources[sourceKey]; if (source?.workspaceId === workspaceId) { yield source.status; } @@ -261,3 +354,12 @@ function getTerminalIdForPane( const terminalId = (pane.data as { terminalId?: unknown }).terminalId; return typeof terminalId === "string" && terminalId ? terminalId : null; } + +function getChatIdForPane( + pane: V2NotificationPaneLike | null | undefined, +): string | null { + if (!pane || pane.kind !== "chat") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const sessionId = (pane.data as { sessionId?: unknown }).sessionId; + return typeof sessionId === "string" && sessionId ? sessionId : null; +} diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index 170ecdcd208..afab40e5384 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -98,12 +98,13 @@ Important shipped pieces: - `apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh` - posts to `SUPERSET_HOST_AGENT_HOOK_URL` - falls back to the v1 hook server on missing URL or non-2xx response -- `apps/desktop/src/renderer/routes/_authenticated/components/V2AgentHookListeners` - - mounts listeners for v2 workspaces +- `apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController` + - mounts one host notification subscriber per host-service URL - `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener` - updates status, suppresses, plays sound, and shows notifications - `apps/desktop/src/renderer/stores/v2-notifications` - - separate v2 status store, aggregated by workspace for the dashboard sidebar + - separate v2 status store, keyed by typed notification source + (`terminal:`, `chat:`) and aggregated by workspace, tab, and pane - `apps/desktop/src/renderer/lib/ringtones` - renderer-side built-in ringtone playback @@ -410,25 +411,26 @@ Audio unlock should stay client-side. The current first-gesture priming is the r ## Host And Workspace Listener Topology -Current topology: +Previous topology: ```text authenticated layout - V2AgentHookListeners - one WorkspaceListener per workspace - useWorkspaceEvent("agent:lifecycle", workspaceId) + V2NotificationController + one HostNotificationSubscriber per host URL + eventBus.on("agent:lifecycle", "*") + eventBus.on("terminal:lifecycle", "*") ``` -Recommended topology: +Current topology: ```text authenticated layout - AgentNotificationControllers + V2NotificationController group open/known workspaces by host URL - one HostAgentNotificationController per host URL + one HostNotificationSubscriber per host URL eventBus.on("agent:lifecycle", "*") eventBus.on("terminal:lifecycle", "*") - resolve event workspace/source locally + resolve event workspace and typed source locally ``` Benefits: From f8337fa310428fcdffdc642861e49d831be7a32f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 19:02:06 -0700 Subject: [PATCH 13/17] feat(desktop): show v2 native notifications from main --- apps/desktop/src/lib/trpc/routers/index.ts | 2 +- .../src/lib/trpc/routers/notifications.ts | 114 +++++++++++++++++- .../useV2AgentHookListener.ts | 78 ++++-------- .../HostNotificationSubscriber.tsx | 3 - .../renderer/routes/_authenticated/layout.tsx | 17 +++ .../stores/tabs/useAgentHookListener.ts | 3 + apps/desktop/src/shared/constants.ts | 1 + apps/desktop/src/shared/notification-types.ts | 9 ++ ...60422-v2-notification-hooks-client-side.md | 13 +- 9 files changed, 174 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 03534fcf43e..c3a0db245b2 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -43,7 +43,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { terminal: createTerminalRouter(), changes: createChangesRouter(), filesystem: createFilesystemRouter(), - notifications: createNotificationsRouter(), + notifications: createNotificationsRouter(getWindow), permissions: createPermissionsRouter(), ports: createPortsRouter(), resourceMetrics: createResourceMetricsRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index 1294af276d8..c50e8383f3f 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,10 +1,17 @@ import { observable } from "@trpc/server/observable"; +import type { + BrowserWindow, + Notification as ElectronNotification, +} from "electron"; +import { Notification } from "electron"; import { type AgentLifecycleEvent, type NotificationIds, notificationsEmitter, } from "main/lib/notifications/server"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import type { V2NotificationSourceFocusTarget } from "shared/notification-types"; +import { z } from "zod"; import { publicProcedure, router } from ".."; type TerminalExitNotification = NotificationIds & { @@ -19,13 +26,101 @@ type NotificationEvent = data?: AgentLifecycleEvent; } | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds } + | { + type: typeof NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE; + data?: V2NotificationSourceFocusTarget; + } | { type: typeof NOTIFICATION_EVENTS.TERMINAL_EXIT; data?: TerminalExitNotification; }; -export const createNotificationsRouter = () => { +const v2NotificationSourceSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("terminal"), id: z.string().min(1) }), + z.object({ type: z.literal("chat"), id: z.string().min(1) }), +]); + +const showNativeInputSchema = z.object({ + title: z.string().min(1), + body: z.string(), + silent: z.boolean().default(true), + clickTarget: z + .object({ + workspaceId: z.string().min(1), + source: v2NotificationSourceSchema, + }) + .optional(), +}); +type ShowNativeInput = z.infer; + +const activeNativeNotifications = new Map(); +let nativeNotificationCounter = 0; + +function focusWindow(getWindow: () => BrowserWindow | null): void { + const window = getWindow(); + if (!window) return; + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); +} + +function getNativeNotificationKey(input: ShowNativeInput): string { + const target = input.clickTarget; + if (!target) return `_native_${nativeNotificationCounter++}`; + return `${target.workspaceId}:${target.source.type}:${target.source.id}`; +} + +function trackNativeNotification( + key: string, + notification: ElectronNotification, +): void { + const previous = activeNativeNotifications.get(key); + previous?.close(); + activeNativeNotifications.set(key, notification); + + const untrack = () => { + if (activeNativeNotifications.get(key) === notification) { + activeNativeNotifications.delete(key); + } + }; + notification.on("click", untrack); + notification.on("close", untrack); +} + +export const createNotificationsRouter = ( + getWindow: () => BrowserWindow | null, +) => { return router({ + showNative: publicProcedure + .input(showNativeInputSchema) + .mutation(({ input }) => { + if (!Notification.isSupported()) { + return { success: false as const, reason: "unsupported" as const }; + } + + const notification = new Notification({ + title: input.title, + body: input.body, + silent: input.silent, + }); + const key = getNativeNotificationKey(input); + trackNativeNotification(key, notification); + + notification.on("click", () => { + focusWindow(getWindow); + if (!input.clickTarget) return; + notificationsEmitter.emit( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + input.clickTarget, + ); + }); + + notification.show(); + return { success: true as const }; + }), + subscribe: publicProcedure.subscription(() => { return observable((emit) => { const onLifecycle = (data: AgentLifecycleEvent) => { @@ -36,6 +131,15 @@ export const createNotificationsRouter = () => { emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); }; + const onFocusV2NotificationSource = ( + data: V2NotificationSourceFocusTarget, + ) => { + emit.next({ + type: NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + data, + }); + }; + const onTerminalExit = (data: TerminalExitNotification) => { emit.next({ type: NOTIFICATION_EVENTS.TERMINAL_EXIT, data }); }; @@ -45,6 +149,10 @@ export const createNotificationsRouter = () => { onLifecycle, ); notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.on( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + onFocusV2NotificationSource, + ); notificationsEmitter.on( NOTIFICATION_EVENTS.TERMINAL_EXIT, onTerminalExit, @@ -56,6 +164,10 @@ export const createNotificationsRouter = () => { onLifecycle, ); notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.off( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + onFocusV2NotificationSource, + ); notificationsEmitter.off( NOTIFICATION_EVENTS.TERMINAL_EXIT, onTerminalExit, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts index 57dcd5f130d..a7a74e934e6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts @@ -5,11 +5,11 @@ import type { } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useRingtoneStore } from "renderer/stores/ringtone"; import { @@ -19,15 +19,12 @@ import { } from "renderer/stores/v2-notifications"; import type { PaneViewerData } from "../../types"; import { - getNotificationSourceId, isV2NotificationTargetVisible, resolveV2NotificationTarget, type V2NotificationTarget, } from "./resolveV2NotificationTarget"; import { resolveV2AgentStatusTransition } from "./statusTransitions"; -type Navigate = ReturnType; - /** * Listens for v2 agent lifecycle events over the host-service WebSocket, * updates pane status indicators (working/review/permission/idle) and @@ -45,7 +42,6 @@ type Navigate = ReturnType; * sidebar. */ export function useV2AgentHookListener(workspaceId: string): void { - const navigate = useNavigate(); const collections = useCollections(); const { data: volume = 100 } = electronTrpc.settings.getNotificationVolume.useQuery(); @@ -76,10 +72,9 @@ export function useV2AgentHookListener(workspaceId: string): void { paneLayout, volume, muted, - navigate, }); }, - [workspaceId, paneLayout, volume, muted, navigate], + [workspaceId, paneLayout, volume, muted], ); const handleTerminalLifecycle = useCallback( @@ -102,14 +97,12 @@ export function handleV2AgentLifecycleEvent({ paneLayout, volume, muted, - navigate, }: { workspaceId: string; payload: AgentLifecyclePayload; paneLayout: WorkspaceState | null | undefined; volume: number; muted: boolean; - navigate: Navigate; }): void { const target = resolveV2NotificationTarget({ workspaceId, @@ -124,9 +117,7 @@ export function handleV2AgentLifecycleEvent({ const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; void playRingtone({ ringtoneId, volume, muted }); - showNativeNotification(payload, workspaceId, () => { - openNotificationTarget(navigate, workspaceId, target); - }); + showNativeNotification({ payload, workspaceId, target }); } export function handleV2TerminalLifecycleEvent({ @@ -207,37 +198,37 @@ function shouldSuppress( }); } -function showNativeNotification( - payload: AgentLifecyclePayload, - workspaceId: string, - onClick: () => void, -): void { - if (typeof Notification === "undefined") return; - if (Notification.permission !== "granted") return; - +function showNativeNotification({ + payload, + workspaceId, + target, +}: { + payload: AgentLifecyclePayload; + workspaceId: 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 tagId = getNotificationSourceId(payload); - - try { - const notification = new Notification(title, { + void electronTrpcClient.notifications.showNative + .mutate({ + title, body, - tag: `${workspaceId}:${tagId}`, silent: true, + clickTarget: { + workspaceId, + source: { type: "terminal", id: target.terminalId }, + }, + }) + .catch((error) => { + console.warn( + "[notifications] failed to show native notification:", + error, + ); }); - notification.onclick = (event) => { - event.preventDefault(); - onClick(); - notification.close(); - }; - } catch { - // Notification constructor can throw if the permission was revoked - // between the check and the call. Non-fatal. - } } function clearSources( @@ -252,22 +243,3 @@ function clearSources( workspaceId, ); } - -function openNotificationTarget( - navigate: Navigate, - workspaceId: string, - target: V2NotificationTarget, -): void { - if (typeof window !== "undefined") { - window.focus(); - localStorage.setItem("lastViewedWorkspaceId", workspaceId); - } - - void navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search: { - terminalId: target.terminalId, - }, - }); -} 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 36c9b068aeb..a3a2a85fd20 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 @@ -4,7 +4,6 @@ import type { TerminalLifecyclePayload, } from "@superset/workspace-client"; import { getEventBus } from "@superset/workspace-client"; -import { useNavigate } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; @@ -26,7 +25,6 @@ export function HostNotificationSubscriber({ hostUrl: string; workspaces: HostNotificationWorkspaceState[]; }): null { - const navigate = useNavigate(); const { data: volume = 100 } = electronTrpc.settings.getNotificationVolume.useQuery(); const { data: muted = false } = @@ -49,7 +47,6 @@ export function HostNotificationSubscriber({ paneLayout: workspace.paneLayout, volume, muted, - navigate, }); }, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index c5d3f4d00ce..8d954c39571 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -86,6 +86,23 @@ function AuthenticatedLayout() { // Update workspace-run pane state on terminal exit electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { + if ( + event.type === NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE && + event.data + ) { + localStorage.setItem("lastViewedWorkspaceId", event.data.workspaceId); + const source = event.data.source; + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: event.data.workspaceId }, + search: + source.type === "terminal" + ? { terminalId: source.id } + : { chatSessionId: source.id }, + }); + return; + } + if ( event.type !== NOTIFICATION_EVENTS.TERMINAL_EXIT || !event.data?.paneId diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index f68c140eb8e..62642d77a90 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -53,6 +53,9 @@ export function useAgentHookListener() { electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { if (!event.data) return; + if (event.type === NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE) { + return; + } const state = useTabsStore.getState(); const target = resolveNotificationTarget(event.data, state); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 4f7a57caa9d..ea8434bd0e7 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -31,6 +31,7 @@ export const CONFIG_TEMPLATE = `{ export const NOTIFICATION_EVENTS = { AGENT_LIFECYCLE: "agent-lifecycle", FOCUS_TAB: "focus-tab", + FOCUS_V2_NOTIFICATION_SOURCE: "focus-v2-notification-source", TERMINAL_EXIT: "terminal-exit", } as const; diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts index 49b5347768e..4e68e6a7979 100644 --- a/apps/desktop/src/shared/notification-types.ts +++ b/apps/desktop/src/shared/notification-types.ts @@ -13,3 +13,12 @@ export interface NotificationIds { export interface AgentLifecycleEvent extends NotificationIds { eventType: "Start" | "Stop" | "PermissionRequest" | "PendingQuestion"; } + +export type V2NotificationSource = + | { type: "terminal"; id: string } + | { type: "chat"; id: string }; + +export interface V2NotificationSourceFocusTarget { + workspaceId: string; + source: V2NotificationSource; +} diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index afab40e5384..c9c29bc88de 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -80,7 +80,7 @@ agent shell hook host-service broadcasts agent:lifecycle over /events WebSocket renderer listener updates v2 notification store renderer suppresses or plays ringtone - renderer shows browser/OS Notification + renderer asks Electron main to show a silent native Notification dashboard sidebar reads aggregated v2 status ``` @@ -101,7 +101,9 @@ Important shipped pieces: - `apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController` - mounts one host notification subscriber per host-service URL - `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener` - - updates status, suppresses, plays sound, and shows notifications + - updates status, suppresses, plays sound, and requests native notifications +- `apps/desktop/src/lib/trpc/routers/notifications.ts` + - creates Electron native notifications and emits v2 source-focus events on click - `apps/desktop/src/renderer/stores/v2-notifications` - separate v2 status store, keyed by typed notification source (`terminal:`, `chat:`) and aggregated by workspace, tab, and pane @@ -114,12 +116,7 @@ This was the right first move, but it should not be the final architecture. The current implementation is useful but incomplete. -- **No v2 terminal-exit cleanup.** V1 clears stuck `working` and `permission` statuses when a terminal exits. V2 status only changes on hook events, so interrupted or killed agents can leave a stale sidebar indicator. -- **No notification click routing.** V1 notification clicks focus the app and route to the target workspace/tab/pane. V2 creates a `Notification` but does not handle clicks. -- **Suppression is too coarse.** If the v2 event lacks `paneId` and `tabId`, suppression falls back to "current workspace is visible." That can suppress a notification for a background pane in the same workspace. The client has v2 pane layout data and should resolve by `terminalId` instead. -- **One listener per workspace is more work than needed.** Event-bus connections are reused per host, but each workspace still mounts a hook and settings queries. A host-level controller should subscribe once per host and fan events into the store. - **The renderer hook is desktop-specific.** It imports `electronTrpc` for settings, so the current path is not actually web-ready. -- **Browser notification permission is not handled.** The v2 client checks `Notification.permission` but does not request permission or route users to settings. - **Custom ringtones are not supported.** The v2 path falls back to the default ringtone when `"custom"` is selected. - **The tests do not cover the new contract.** The copied host-service event mapper, hook mutation, v2 status transitions, suppression, audio fallback, and notification click behavior need direct tests. @@ -389,7 +386,7 @@ On notification click: - if only `terminalId` is known, resolve it through pane layout and activate the matching pane - if no pane can be resolved, navigate to the workspace and clear review attention for that source/workspace -V1 did this through Electron main emitting `FOCUS_TAB`. V2 should do it in the client controller through a platform-specific focus adapter. +V1 did this through Electron main emitting `FOCUS_TAB`. V2 now emits a typed source-focus event from Electron main, and the renderer routes to the v2 workspace with `terminalId` or `chatSessionId` search params. ## Ringtones And Preferences From 56812fa473090b86a09a9dc74be9b3151c0b23fd Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 19:59:34 -0700 Subject: [PATCH 14/17] fix(desktop): polish v2 notification focus and audio --- .../src/lib/trpc/routers/ringtone/index.ts | 31 +++++++++++++++ .../src/renderer/lib/ringtones/play.ts | 25 +++++++++--- .../useConsumeAutomationRunLink.test.ts | 38 +++++++++++++++++++ .../useConsumeAutomationRunLink.ts | 32 ++++++++++++++-- .../v2-workspace/$workspaceId/page.tsx | 15 +++++++- .../renderer/routes/_authenticated/layout.tsx | 10 ++++- 6 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index 699cba29806..c760f3ad567 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -11,6 +11,7 @@ import { playSoundFile } from "main/lib/play-sound"; import { getSoundPath } from "main/lib/sound-paths"; import { CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, getRingtoneFilename, isBuiltInRingtoneId, } from "shared/ringtones"; @@ -92,6 +93,15 @@ function getRingtoneSoundPath(ringtoneId: string): string | null { return getSoundPath(filename); } +function getNotificationRingtoneSoundPath(ringtoneId: string): string | null { + const soundPath = getRingtoneSoundPath(ringtoneId); + if (soundPath) return soundPath; + + if (ringtoneId !== CUSTOM_RINGTONE_ID) return null; + const fallbackFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID); + return fallbackFilename ? getSoundPath(fallbackFilename) : null; +} + /** * Ringtone router for audio preview and playback operations */ @@ -117,6 +127,27 @@ export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => { return { success: true as const }; }), + /** + * Play the selected notification ringtone from main when the renderer cannot + * access the backing asset directly, namely imported custom audio files. + */ + playNotification: publicProcedure + .input( + z.object({ + ringtoneId: z.string(), + volume: z.number().min(0).max(100).optional(), + }), + ) + .mutation(({ input }) => { + const soundPath = getNotificationRingtoneSoundPath(input.ringtoneId); + if (!soundPath) { + return { success: true as const }; + } + + playSoundFile(soundPath, input.volume ?? 100); + return { success: true as const }; + }), + /** * Stop the currently playing ringtone preview */ diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts index 883936e16a8..e4d17fc7561 100644 --- a/apps/desktop/src/renderer/lib/ringtones/play.ts +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -3,6 +3,7 @@ import { DEFAULT_RINGTONE_ID, getRingtoneById, } from "shared/ringtones"; +import { electronTrpcClient } from "../trpc-client"; import { builtInRingtoneUrls } from "./urls"; export interface PlayRingtoneOptions { @@ -65,13 +66,12 @@ export function primeRingtoneAudioOnFirstGesture(): void { } /** - * Resolve the bundled audio URL for a ringtone id. Custom uploads are not - * wired into renderer playback yet, so custom and unknown ids fall back to the - * default built-in ringtone. + * Resolve the bundled audio URL for a built-in ringtone id. Custom uploads are + * stored outside the Vite bundle, so they are played by main on renderer + * request instead of exposing local file paths to the web runtime. */ function resolveRingtoneUrl(ringtoneId: string): string | null { - const ringtone = - ringtoneId === CUSTOM_RINGTONE_ID ? null : getRingtoneById(ringtoneId); + const ringtone = getRingtoneById(ringtoneId); const resolved = ringtone ? builtInRingtoneUrls[ringtone.filename] : undefined; @@ -83,9 +83,22 @@ function resolveRingtoneUrl(ringtoneId: string): string | null { export async function playRingtone(opts: PlayRingtoneOptions): Promise { if (opts.muted) return; - const volume = Math.max(0, Math.min(1, opts.volume / 100)); + const volumePercent = Math.max(0, Math.min(100, opts.volume)); + const volume = volumePercent / 100; if (volume === 0) return; + if (opts.ringtoneId === CUSTOM_RINGTONE_ID) { + try { + await electronTrpcClient.ringtone.playNotification.mutate({ + ringtoneId: opts.ringtoneId, + volume: volumePercent, + }); + } catch (error) { + console.warn("[ringtone] custom playback failed:", error); + } + return; + } + const url = resolveRingtoneUrl(opts.ringtoneId); if (!url) return; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts new file mode 100644 index 00000000000..c70f1918d56 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { getAutomationRunLinkConsumeKey } from "./useConsumeAutomationRunLink"; + +describe("getAutomationRunLinkConsumeKey", () => { + it("dedupes plain automation links by source id", () => { + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: undefined, + }), + ).toBe("terminal:terminal-1"); + expect( + getAutomationRunLinkConsumeKey({ + type: "chat", + id: "chat-1", + focusRequestId: undefined, + }), + ).toBe("chat:chat-1"); + }); + + it("treats each notification focus request as a fresh command", () => { + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: "request-1", + }), + ).toBe("terminal:terminal-1:focus:request-1"); + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: "request-2", + }), + ).toBe("terminal:terminal-1:focus:request-2"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts index a1067285763..95c10e8455d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts @@ -11,6 +11,7 @@ interface UseConsumeAutomationRunLinkArgs { store: StoreApi>; terminalId: string | undefined; chatSessionId: string | undefined; + focusRequestId: string | undefined; } /** @@ -23,24 +24,47 @@ export function useConsumeAutomationRunLink({ store, terminalId, chatSessionId, + focusRequestId, }: UseConsumeAutomationRunLinkArgs): void { const consumedRef = useRef>(new Set()); useEffect(() => { if (!terminalId) return; - const key = `terminal:${terminalId}`; + const key = getAutomationRunLinkConsumeKey({ + type: "terminal", + id: terminalId, + focusRequestId, + }); if (consumedRef.current.has(key)) return; consumedRef.current.add(key); focusOrAddTerminalPane(store, terminalId); - }, [store, terminalId]); + }, [store, terminalId, focusRequestId]); useEffect(() => { if (!chatSessionId) return; - const key = `chat:${chatSessionId}`; + const key = getAutomationRunLinkConsumeKey({ + type: "chat", + id: chatSessionId, + focusRequestId, + }); if (consumedRef.current.has(key)) return; consumedRef.current.add(key); focusOrAddChatPane(store, chatSessionId); - }, [store, chatSessionId]); + }, [store, chatSessionId, focusRequestId]); +} + +export function getAutomationRunLinkConsumeKey({ + type, + id, + focusRequestId, +}: { + type: "terminal" | "chat"; + id: string; + focusRequestId: string | undefined; +}): string { + return focusRequestId + ? `${type}:${id}:focus:${focusRequestId}` + : `${type}:${id}`; } function focusOrAddTerminalPane( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 073bc0e29a8..2713582dace 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -64,6 +64,7 @@ import type { interface WorkspaceSearch { terminalId?: string; chatSessionId?: string; + focusRequestId?: string; } export const Route = createFileRoute( @@ -74,12 +75,14 @@ export const Route = createFileRoute( terminalId: typeof raw.terminalId === "string" ? raw.terminalId : undefined, chatSessionId: typeof raw.chatSessionId === "string" ? raw.chatSessionId : undefined, + focusRequestId: + typeof raw.focusRequestId === "string" ? raw.focusRequestId : undefined, }), }); function V2WorkspacePage() { const { workspaceId } = Route.useParams(); - const { terminalId, chatSessionId } = Route.useSearch(); + const { terminalId, chatSessionId, focusRequestId } = Route.useSearch(); const collections = useCollections(); const { data: workspaces } = useLiveQuery( @@ -106,6 +109,7 @@ function V2WorkspacePage() { workspaceName={workspace.name} terminalId={terminalId} chatSessionId={chatSessionId} + focusRequestId={focusRequestId} /> ); } @@ -147,12 +151,14 @@ function WorkspaceContent({ workspaceName, terminalId, chatSessionId, + focusRequestId, }: { projectId: string; workspaceId: string; workspaceName: string; terminalId?: string; chatSessionId?: string; + focusRequestId?: string; }) { const { preferences: v2UserPreferences, @@ -170,7 +176,12 @@ function WorkspaceContent({ projectId, }); useConsumePendingLaunch({ workspaceId, store }); - useConsumeAutomationRunLink({ store, terminalId, chatSessionId }); + useConsumeAutomationRunLink({ + store, + terminalId, + chatSessionId, + focusRequestId, + }); const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ id: workspaceId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 8d954c39571..704d00024dd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -97,8 +97,14 @@ function AuthenticatedLayout() { params: { workspaceId: event.data.workspaceId }, search: source.type === "terminal" - ? { terminalId: source.id } - : { chatSessionId: source.id }, + ? { + terminalId: source.id, + focusRequestId: crypto.randomUUID(), + } + : { + chatSessionId: source.id, + focusRequestId: crypto.randomUUID(), + }, }); return; } From ebb8ad81e80413f9f41394267930b5bb601ff816 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 22:25:44 -0700 Subject: [PATCH 15/17] [codex] Align ringtone playback with VS Code pattern --- .../src/renderer/lib/ringtones/play.ts | 78 ++++++------------- .../renderer/routes/_authenticated/layout.tsx | 7 -- 2 files changed, 25 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts index e4d17fc7561..d807377b6c2 100644 --- a/apps/desktop/src/renderer/lib/ringtones/play.ts +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -13,57 +13,7 @@ export interface PlayRingtoneOptions { muted: boolean; } -let audioPrimed = false; -let audioPrimingListenersInstalled = false; -let audioPrimingInFlight = false; - -/** - * Some browsers block `audio.play()` until the user has interacted with the - * page. Wire this up once at app mount so the first pointerdown unlocks - * autoplay and subsequent hook events can play without a visible gesture. - * Safe to call repeatedly — listeners are only installed once. - */ -export function primeRingtoneAudioOnFirstGesture(): void { - if (audioPrimed || typeof window === "undefined") return; - if (audioPrimingListenersInstalled || audioPrimingInFlight) return; - - const removeListeners = () => { - window.removeEventListener("pointerdown", prime); - window.removeEventListener("keydown", prime); - audioPrimingListenersInstalled = false; - }; - - const installListeners = () => { - if (audioPrimed || audioPrimingListenersInstalled || audioPrimingInFlight) { - return; - } - window.addEventListener("pointerdown", prime, { once: true }); - window.addEventListener("keydown", prime, { once: true }); - audioPrimingListenersInstalled = true; - }; - - const prime = () => { - if (audioPrimed || audioPrimingInFlight) return; - audioPrimingInFlight = true; - removeListeners(); - - const silent = new Audio(); - silent.muted = true; - silent - .play() - .then(() => { - audioPrimed = true; - audioPrimingInFlight = false; - }) - .catch(() => { - // Browser refused even with a gesture — wait for the next one. - audioPrimingInFlight = false; - installListeners(); - }); - }; - - installListeners(); -} +const builtInAudioByUrl = new Map(); /** * Resolve the bundled audio URL for a built-in ringtone id. Custom uploads are @@ -81,6 +31,25 @@ function resolveRingtoneUrl(ringtoneId: string): string | null { return fallback ? (builtInRingtoneUrls[fallback.filename] ?? null) : null; } +function getBuiltInAudio(url: string): HTMLAudioElement { + let audio = builtInAudioByUrl.get(url); + if (!audio) { + audio = new Audio(url); + audio.preload = "auto"; + builtInAudioByUrl.set(url, audio); + } + return audio; +} + +function isUserGesturePlaybackError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.name === "NotAllowedError" || + error.message.includes("user gesture") || + error.message.includes("not allowed") + ); +} + export async function playRingtone(opts: PlayRingtoneOptions): Promise { if (opts.muted) return; const volumePercent = Math.max(0, Math.min(100, opts.volume)); @@ -102,12 +71,15 @@ export async function playRingtone(opts: PlayRingtoneOptions): Promise { const url = resolveRingtoneUrl(opts.ringtoneId); if (!url) return; - const audio = new Audio(url); + const audio = getBuiltInAudio(url); audio.volume = volume; + audio.currentTime = 0; try { await audio.play(); } catch (error) { - console.warn("[ringtone] autoplay blocked or failed:", error); + if (!isUserGesturePlaybackError(error)) { + console.warn("[ringtone] playback failed:", error); + } } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 3075ba7d667..90c9949054b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -21,7 +21,6 @@ import { migrateHotkeyOverrides } from "renderer/hotkeys/migrate"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; import { dragDropManager } from "renderer/lib/dnd"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { primeRingtoneAudioOnFirstGesture } from "renderer/lib/ringtones/play"; import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showWorkspaceAutoNameWarningToast"; import { InitGitDialog } from "renderer/react-query/projects/InitGitDialog"; import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal"; @@ -77,12 +76,6 @@ function AuthenticatedLayout() { }); }, []); - // Unlock browser audio autoplay so v2 agent-hook sounds can play without - // a visible user gesture on each event. - useEffect(() => { - primeRingtoneAudioOnFirstGesture(); - }, []); - // Update workspace-run pane state on terminal exit electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { From 994273222aa08ec902d9f800da6fbfd7532ac15f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 22:36:04 -0700 Subject: [PATCH 16/17] [codex] Move v2 notification lifecycle logic --- .../hooks/useV2AgentHookListener/index.ts | 5 -- .../HostNotificationSubscriber.tsx | 4 +- .../lib/lifecycleEvents.ts} | 80 +++---------------- .../lib}/resolveV2NotificationTarget.test.ts | 9 +-- .../lib}/resolveV2NotificationTarget.ts | 11 +-- .../lib}/statusTransitions.test.ts | 0 .../lib}/statusTransitions.ts | 0 7 files changed, 18 insertions(+), 91 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts rename apps/desktop/src/renderer/routes/_authenticated/{_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts => components/V2NotificationController/lib/lifecycleEvents.ts} (65%) rename apps/desktop/src/renderer/routes/_authenticated/{_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener => components/V2NotificationController/lib}/resolveV2NotificationTarget.test.ts (91%) rename apps/desktop/src/renderer/routes/_authenticated/{_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener => components/V2NotificationController/lib}/resolveV2NotificationTarget.ts (90%) rename apps/desktop/src/renderer/routes/_authenticated/{_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener => components/V2NotificationController/lib}/statusTransitions.test.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/{_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener => components/V2NotificationController/lib}/statusTransitions.ts (100%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts deleted file mode 100644 index dd254327853..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - handleV2AgentLifecycleEvent, - handleV2TerminalLifecycleEvent, - useV2AgentHookListener, -} from "./useV2AgentHookListener"; 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 a3a2a85fd20..f8e7cbdf85e 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 @@ -7,11 +7,11 @@ import { getEventBus } from "@superset/workspace-client"; import { useEffect, useEffectEvent, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { handleV2AgentLifecycleEvent, handleV2TerminalLifecycleEvent, -} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener"; -import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +} from "../../lib/lifecycleEvents"; export interface HostNotificationWorkspaceState { workspaceId: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts similarity index 65% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts index a7a74e934e6..23fafcd9f70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/useV2AgentHookListener.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -3,21 +3,15 @@ import type { AgentLifecyclePayload, TerminalLifecyclePayload, } from "@superset/workspace-client"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useCallback, useMemo } from "react"; -import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { playRingtone } from "renderer/lib/ringtones/play"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useRingtoneStore } from "renderer/stores/ringtone"; import { getV2TerminalNotificationSource, useV2NotificationStore, type V2NotificationSourceInput, } from "renderer/stores/v2-notifications"; -import type { PaneViewerData } from "../../types"; import { isV2NotificationTargetVisible, resolveV2NotificationTarget, @@ -26,71 +20,19 @@ import { import { resolveV2AgentStatusTransition } from "./statusTransitions"; /** - * Listens for v2 agent lifecycle events over the host-service WebSocket, - * updates pane status indicators (working/review/permission/idle) and - * plays the selected ringtone in the renderer. Mirrors the v1 electron-main - * playback path (see apps/desktop/src/main/lib/notifications/notification-manager.ts) - * plus the v1 sidebar-status path (renderer/stores/tabs/useAgentHookListener.ts), - * but runs client-side so it works when host-service is off-machine. + * Handles v2 lifecycle events received by V2NotificationController. Updates + * pane status indicators (working/review/permission/idle) and plays the + * selected ringtone in the renderer. + * + * Mirrors the v1 electron-main playback path + * (apps/desktop/src/main/lib/notifications/notification-manager.ts) plus the + * v1 sidebar-status path (renderer/stores/tabs/useAgentHookListener.ts), but + * runs client-side so it works when host-service is off-machine. * * Keeps v1 behavior: skip `Start` for sound, suppress when the event's * pane is visible and the window is focused, and honor the existing * mute/volume settings. - * - * The layout-level `V2NotificationController` component is the active mount path: - * it subscribes once per host so backgrounded workspaces also light up the - * sidebar. */ -export function useV2AgentHookListener(workspaceId: string): void { - const collections = useCollections(); - const { data: volume = 100 } = - electronTrpc.settings.getNotificationVolume.useQuery(); - const { data: muted = false } = - electronTrpc.settings.getNotificationSoundsMuted.useQuery(); - const { data: localWorkspaceRows = [] } = useLiveQuery( - (query) => - query - .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) - .where(({ v2WorkspaceLocalState }) => - eq(v2WorkspaceLocalState.workspaceId, workspaceId), - ), - [collections, workspaceId], - ); - const paneLayout = useMemo( - () => - (localWorkspaceRows[0]?.paneLayout as - | WorkspaceState - | undefined) ?? null, - [localWorkspaceRows], - ); - - const handleEvent = useCallback( - (payload: AgentLifecyclePayload) => { - handleV2AgentLifecycleEvent({ - workspaceId, - payload, - paneLayout, - volume, - muted, - }); - }, - [workspaceId, paneLayout, volume, muted], - ); - - const handleTerminalLifecycle = useCallback( - (payload: TerminalLifecyclePayload) => { - handleV2TerminalLifecycleEvent({ - workspaceId, - payload, - }); - }, - [workspaceId], - ); - - useWorkspaceEvent("agent:lifecycle", workspaceId, handleEvent); - useWorkspaceEvent("terminal:lifecycle", workspaceId, handleTerminalLifecycle); -} - export function handleV2AgentLifecycleEvent({ workspaceId, payload, @@ -175,8 +117,8 @@ function updatePaneStatus( function getCurrentWorkspaceId(): string | null { try { // Matches both v1 `/workspace/` and v2 `/v2-workspace/` - // routes — the hook runs in a mixed-UI window so either can be - // the active URL while an event arrives. + // routes. Notifications are layout-level, so either can be active + // while an event arrives. const match = window.location.hash.match(/\/(?:v2-)?workspace\/([^/?#]+)/); return match ? decodeURIComponent(match[1] ?? "") : null; } catch { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts similarity index 91% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts index bf785075ca2..2dffbbd62e6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "bun:test"; import type { WorkspaceState } from "@superset/panes"; import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import type { PaneViewerData } from "../../types"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { - getNotificationSourceId, isV2NotificationTargetVisible, resolveTerminalTarget, resolveV2NotificationTarget, @@ -119,10 +118,4 @@ describe("resolveV2NotificationTarget", () => { }), ).toBe(false); }); - - it("uses the terminal id as the native notification source id", () => { - expect(getNotificationSourceId(payload({ terminalId: "terminal-1" }))).toBe( - "terminal-1", - ); - }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts similarity index 90% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts index 08332c436d7..f520ed68270 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/resolveV2NotificationTarget.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts @@ -1,6 +1,9 @@ import type { WorkspaceState } from "@superset/panes"; import type { AgentLifecyclePayload } from "@superset/workspace-client"; -import type { PaneViewerData, TerminalPaneData } from "../../types"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; export interface V2NotificationTarget { workspaceId: string; @@ -9,12 +12,6 @@ export interface V2NotificationTarget { terminalId: string; } -export function getNotificationSourceId( - payload: Pick, -): string { - return payload.terminalId; -} - export function resolveV2NotificationTarget({ workspaceId, payload, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener/statusTransitions.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts From 2fa10791c77e7d4115caa9fba84fca0176f65b80 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 22:40:06 -0700 Subject: [PATCH 17/17] [codex] Refresh notification docs and v1 fallback test --- .../main/lib/agent-setup/notify-hook.test.ts | 21 +++++++++++++++++++ ...60422-v2-notification-hooks-client-side.md | 20 +++++++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index d5b1c9380ed..ed81ec70bbb 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -35,4 +35,25 @@ describe("getNotifyScriptContent", () => { 'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5', ); }); + + it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => { + const script = readFileSync( + path.join(import.meta.dir, "templates", "notify-hook.template.sh"), + "utf-8", + ); + + expect(script).toContain('if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then'); + expect(script).toContain( + '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0', + ); + expect(script).toContain( + 'curl -sG "http://127.0.0.1:' + + "$" + + "{SUPERSET_PORT:-{{DEFAULT_PORT}}}" + + '/hook/complete"', + ); + expect(script).toContain('--data-urlencode "paneId=$SUPERSET_PANE_ID"'); + expect(script).toContain('--data-urlencode "tabId=$SUPERSET_TAB_ID"'); + expect(script).toContain('--data-urlencode "sessionId=$SESSION_ID"'); + }); }); diff --git a/plans/20260422-v2-notification-hooks-client-side.md b/plans/20260422-v2-notification-hooks-client-side.md index c9c29bc88de..7f604374a4a 100644 --- a/plans/20260422-v2-notification-hooks-client-side.md +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -24,7 +24,7 @@ The shipped v2 path moved playback out of Electron main, which is the important - Preserve the good parts of v1 UX: - sound on completion and permission/input requests - no sound on start events - - mute, volume, selected ringtone, and eventually custom ringtone support + - mute, volume, selected ringtone, and custom ringtone support on desktop - suppress notifications when the user is already looking at the relevant pane - sidebar indicators for working, permission, and review states - click a notification to focus the relevant workspace/pane @@ -51,7 +51,7 @@ V1 was not all bad. The target design should keep these behaviors: - Notifications are suppressed when the target pane is visible and the window is focused. - Native notification clicks focus the app and route the renderer to the target tab/pane. - Terminal exit events clear `working` and `permission` states so interrupted agents do not leave permanent sidebar dots. -- Notification audio honors mute, volume, selected ringtone, and custom ringtone fallback. +- Notification audio honors mute, volume, selected ringtone, and custom ringtone playback. ## What V1 Got Wrong @@ -100,8 +100,7 @@ Important shipped pieces: - falls back to the v1 hook server on missing URL or non-2xx response - `apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController` - mounts one host notification subscriber per host-service URL -- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2AgentHookListener` - - updates status, suppresses, plays sound, and requests native notifications + - owns lifecycle event handling, status transitions, suppression, ringtone playback, and native notification requests - `apps/desktop/src/lib/trpc/routers/notifications.ts` - creates Electron native notifications and emits v2 source-focus events on click - `apps/desktop/src/renderer/stores/v2-notifications` @@ -109,6 +108,7 @@ Important shipped pieces: (`terminal:`, `chat:`) and aggregated by workspace, tab, and pane - `apps/desktop/src/renderer/lib/ringtones` - renderer-side built-in ringtone playback + - Electron-main playback for imported custom ringtone files This was the right first move, but it should not be the final architecture. @@ -116,9 +116,9 @@ This was the right first move, but it should not be the final architecture. The current implementation is useful but incomplete. -- **The renderer hook is desktop-specific.** It imports `electronTrpc` for settings, so the current path is not actually web-ready. -- **Custom ringtones are not supported.** The v2 path falls back to the default ringtone when `"custom"` is selected. -- **The tests do not cover the new contract.** The copied host-service event mapper, hook mutation, v2 status transitions, suppression, audio fallback, and notification click behavior need direct tests. +- **The controller is still desktop-specific.** It imports Electron tRPC-backed settings and native notification APIs, so the current path is not actually web-ready. +- **Custom ringtone storage is still desktop-local.** Imported files are played through Electron main; web will need synced metadata plus browser-readable assets. +- **Integration coverage is still thin.** The pure transition and hook mutation paths are covered, but there is no end-to-end shell hook -> host-service event bus -> renderer assertion yet. ## Target Architecture @@ -396,7 +396,7 @@ Desktop phase: - read existing local-db settings through Electron tRPC - keep built-in renderer playback -- keep `"custom"` fallback to default until custom playback is implemented +- ask Electron main to play imported custom ringtone files so local paths are not exposed to the renderer Web phase: @@ -404,7 +404,7 @@ Web phase: - store custom ringtones outside local filesystem, for example R2 plus IndexedDB cache - add cross-tab leadership so only one tab plays sound -Audio unlock should stay client-side. The current first-gesture priming is the right kind of workaround, but it should live with the notification platform implementation. +Audio playback should stay client-side and best-effort. The desktop renderer now follows the VS Code-style `HTMLAudioElement.play()` pattern: cache real audio elements, suppress expected user-gesture/autoplay failures, and avoid global Electron autoplay-policy overrides. ## Host And Workspace Listener Topology @@ -468,7 +468,7 @@ Renderer/client unit tests: - suppression only happens for focused, visible target panes. - unresolved workspace-only events are not over-suppressed. - notification click calls the focus adapter with the resolved target. -- custom ringtone falls back consistently until full support lands. +- imported custom ringtone playback goes through Electron main, while built-in ringtone playback stays in the renderer. Integration tests: