diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index b307d2ab1a1..d66c6b82385 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -70,7 +70,7 @@ export const createAppRouter = ( changes: createChangesRouter(), filesystem: createFilesystemRouter(), githubMetrics: createGitHubMetricsRouter(), - 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/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index e84cf44d069..6514a273dd8 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -33,6 +33,7 @@ import { } from "main/lib/youtube-ringtone"; import { CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, getRingtoneFilename, isBuiltInRingtoneId, } from "shared/ringtones"; @@ -114,6 +115,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 */ @@ -139,6 +149,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/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 29db9b10656..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 @@ -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,47 @@ 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", + ); + }); + + 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', + ); + }); + + 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/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 index 8612f89830f..4a5ace82357 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 @@ -22,7 +22,13 @@ $ResourceId = Get-JsonStringValue $Json 'resourceId' if (-not $ResourceId) { $ResourceId = Get-JsonStringValue $Json 'resource_id' } $SessionId = if ($ResourceId) { $ResourceId } else { $HookSessionId } -if (-not $env:SUPERSET_TAB_ID -and -not $SessionId) { exit 0 } +# v2 terminal hooks identify the runtime by SUPERSET_TERMINAL_ID. +# v1 fallback still uses tab/session fields, so guard accordingly. +if ($env:SUPERSET_HOST_AGENT_HOOK_URL) { + if (-not $env:SUPERSET_TERMINAL_ID) { exit 0 } +} else { + if (-not $env:SUPERSET_TAB_ID -and -not $SessionId) { exit 0 } +} $EventType = Get-JsonStringValue $Json 'hook_event_name' if (-not $EventType) { @@ -52,7 +58,27 @@ if ($env:SUPERSET_DEBUG_HOOKS) { } if ($DebugEnabled) { - [Console]::Error.WriteLine("[notify-hook] event=$EventType sessionId=$SessionId hookSessionId=$HookSessionId resourceId=$ResourceId paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID wrapperPid=$env:SUPERSET_WRAPPER_PID") + [Console]::Error.WriteLine("[notify-hook] event=$EventType terminalId=$env:SUPERSET_TERMINAL_ID sessionId=$SessionId hookSessionId=$HookSessionId resourceId=$ResourceId paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID wrapperPid=$env:SUPERSET_WRAPPER_PID") +} + +# 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. Falls through to v1 on non-2xx or network error. +if ($env:SUPERSET_HOST_AGENT_HOOK_URL) { + $V2Payload = "{`"json`":{`"terminalId`":`"$($env:SUPERSET_TERMINAL_ID)`",`"eventType`":`"$EventType`"}}" + try { + $V2Response = Invoke-WebRequest -Uri $env:SUPERSET_HOST_AGENT_HOOK_URL ` + -Method Post -UseBasicParsing -TimeoutSec 5 ` + -ContentType 'application/json' -Body $V2Payload -ErrorAction Stop + if ($DebugEnabled) { + [Console]::Error.WriteLine("[notify-hook] host-service dispatched status=$($V2Response.StatusCode)") + } + if ($V2Response.StatusCode -ge 200 -and $V2Response.StatusCode -lt 300) { exit 0 } + } catch { + if ($DebugEnabled) { + [Console]::Error.WriteLine("[notify-hook] host-service dispatch failed, falling back to v1: $_") + } + } } $Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' } 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 13de189926f..a3481400a33 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" @@ -70,9 +76,40 @@ 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 wrapperPid=$SUPERSET_WRAPPER_PID" >&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. +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 the URL is provided by +# host-service's terminal env. Endpoint is unauthenticated — it only +# 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\":{\"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 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then + echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 + fi + + case "$STATUS_CODE" in + 2*) exit 0 ;; + esac 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..376db75fbf9 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -1,6 +1,8 @@ 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"; @@ -24,11 +26,25 @@ 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: "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: GitChangedPayload) => void) + | ((payload: AgentLifecyclePayload) => void) + | ((payload: TerminalLifecyclePayload) => void), enabled = true, ): void { const hostUrl = useWorkspaceHostUrl(workspaceId); @@ -52,6 +68,24 @@ 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 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 new file mode 100644 index 00000000000..d807377b6c2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -0,0 +1,85 @@ +import { + CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, + getRingtoneById, +} from "shared/ringtones"; +import { electronTrpcClient } from "../trpc-client"; +import { builtInRingtoneUrls } from "./urls"; + +export interface PlayRingtoneOptions { + ringtoneId: string; + /** 0..100 — matches the existing `notificationVolume` setting shape. */ + volume: number; + muted: boolean; +} + +const builtInAudioByUrl = new Map(); + +/** + * 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 = 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; +} + +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)); + 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; + + const audio = getBuiltInAudio(url); + audio.volume = volume; + audio.currentTime = 0; + + try { + await audio.play(); + } catch (error) { + if (!isUserGesturePlaybackError(error)) { + console.warn("[ringtone] playback 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..02de089f8b2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/urls.ts @@ -0,0 +1,48 @@ +/** + * 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/lib/terminal/line-edit-translations.ts b/apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts new file mode 100644 index 00000000000..7753993fdc9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts @@ -0,0 +1,42 @@ +export interface LineEditChordOptions { + isMac: boolean; + isWindows: boolean; +} + +/** True when `mod` is the only non-shift modifier held. */ +function onlyMod(event: KeyboardEvent, mod: "meta" | "alt" | "ctrl"): boolean { + return ( + event.metaKey === (mod === "meta") && + event.altKey === (mod === "alt") && + event.ctrlKey === (mod === "ctrl") && + !event.shiftKey + ); +} + +/** + * Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into + * the escape sequences shells expect. Returns the bytes to send, or null if + * this chord isn't a line-edit translation. + */ +export function translateLineEditChord( + event: KeyboardEvent, + options: LineEditChordOptions, +): string | null { + const { isMac, isWindows } = options; + const { key } = event; + + if (isMac && onlyMod(event, "meta")) { + if (key === "Backspace") return "\x15\x1b[D"; + if (key === "ArrowLeft") return "\x01"; + if (key === "ArrowRight") return "\x05"; + } + if (isMac && onlyMod(event, "alt")) { + if (key === "ArrowLeft") return "\x1bb"; + if (key === "ArrowRight") return "\x1bf"; + } + if (isWindows && onlyMod(event, "ctrl")) { + if (key === "ArrowLeft") return "\x1bb"; + if (key === "ArrowRight") return "\x1bf"; + } + return null; +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 94fa490ee91..30740ace5eb 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -11,6 +11,7 @@ import { import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import type { TerminalAppearance } from "./appearance"; import { logTerminalWrite, terminalRendererDebug } from "./debug"; +import { translateLineEditChord } from "./line-edit-translations"; import { loadAddons } from "./terminal-addons"; const SERIALIZE_SCROLLBACK = 1000; @@ -74,48 +75,6 @@ function createKeyEventHandler(terminal: XTerm) { }; } -/** True when `mod` is the only non-shift modifier held. */ -function onlyMod(event: KeyboardEvent, mod: "meta" | "alt" | "ctrl"): boolean { - return ( - event.metaKey === (mod === "meta") && - event.altKey === (mod === "alt") && - event.ctrlKey === (mod === "ctrl") && - !event.shiftKey - ); -} - -/** - * Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into - * the escape sequences shells expect. Returns the bytes to send, or null if - * this chord isn't a line-edit translation. - * - * Mirrors v1 helpers.ts:319-427. These translations only exist because xterm's - * default encoding (with kitty on) would send a CSI-u sequence that most - * shells don't map to line-edit commands. - */ -function translateLineEditChord( - event: KeyboardEvent, - options: { isMac: boolean; isWindows: boolean }, -): string | null { - const { isMac, isWindows } = options; - const { key } = event; - - if (isMac && onlyMod(event, "meta")) { - if (key === "Backspace") return "\x15\x1b[D"; - if (key === "ArrowLeft") return "\x01"; - if (key === "ArrowRight") return "\x05"; - } - if (isMac && onlyMod(event, "alt")) { - if (key === "ArrowLeft") return "\x1bb"; - if (key === "ArrowRight") return "\x1bf"; - } - if (isWindows && onlyMod(event, "ctrl")) { - if (key === "ArrowLeft") return "\x1bb"; - if (key === "ArrowRight") return "\x1bf"; - } - return null; -} - export interface TerminalRuntime { terminalId: string; terminal: XTerm; 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 e8ee22f67d8..f01141918cd 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,5 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { useV2WorkspaceNotificationStatus } from "renderer/stores/v2-notifications"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; @@ -34,6 +35,7 @@ export function DashboardSidebarWorkspaceItem({ creationStatus, } = workspace; const diffStats = useDiffStats(id); + const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, handleClick, @@ -84,6 +86,7 @@ export function DashboardSidebarWorkspaceItem({ hostType={hostType} hostIsOnline={hostIsOnline} isActive={isActive} + workspaceStatus={workspaceStatus} onClick={isPending ? handlePendingClick : handleClick} creationStatus={creationStatus} disabled={isPending} @@ -148,6 +151,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 f268426407f..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 @@ -12,6 +12,7 @@ import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; +import type { ActivePaneStatus } from "shared/tabs-types"; import type { DashboardSidebarWorkspace, DashboardSidebarWorkspacePullRequest, @@ -38,6 +39,7 @@ interface DashboardSidebarExpandedWorkspaceRowProps renameValue: string; shortcutLabel?: string; diffStats: DiffStats | null; + workspaceStatus?: ActivePaneStatus | null; onClick?: () => void; onDoubleClick?: () => void; onDeleteClick: () => void; @@ -58,6 +60,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< renameValue, shortcutLabel, diffStats, + workspaceStatus = null, onClick, onDoubleClick, onDeleteClick, @@ -154,7 +157,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< hostIsOnline={hostIsOnline} isActive={isActive} variant="expanded" - workspaceStatus={null} + workspaceStatus={workspaceStatus} creationStatus={creationStatus} pullRequestState={pullRequest.state} /> @@ -166,7 +169,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< hostIsOnline={hostIsOnline} isActive={isActive} variant="expanded" - workspaceStatus={null} + workspaceStatus={workspaceStatus} creationStatus={creationStatus} pullRequestState={null} /> 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..55aa9879e9b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx @@ -0,0 +1,21 @@ +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { + useV2SourcesNotificationStatus, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; + +interface V2NotificationStatusIndicatorProps { + workspaceId: string; + sources: Iterable; + className?: string; +} + +export function V2NotificationStatusIndicator({ + workspaceId, + sources, + className, +}: V2NotificationStatusIndicatorProps) { + const status = useV2SourcesNotificationStatus(workspaceId, sources); + 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/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/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 7dda40dd18c..12d6ce4cb2a 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 @@ -31,7 +31,9 @@ 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 { getV2NotificationSourcesForPane } from "renderer/stores/v2-notifications"; import { isSpreadsheetFile } from "shared/file-types"; +import { V2NotificationStatusIndicator } from "../../components/V2NotificationStatusIndicator"; import { getDocument, useSharedFileDocument, @@ -320,7 +322,13 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderTitle: (ctx: RendererContext) => ( - + <> + + + ), renderHeaderExtras: (ctx: RendererContext) => ( @@ -477,6 +485,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/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 1fa0f09db63..7a3a632829c 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 @@ -3,6 +3,7 @@ import { type PaneActionConfig, type SplitPath, Workspace, + type WorkspaceStore, } from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; import { @@ -31,13 +32,21 @@ import { CommandPalette, useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; +import { + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + useV2NotificationStore, + useV2PaneNotificationStatus, +} from "renderer/stores/v2-notifications"; import { toAbsoluteWorkspacePath, 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 { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator"; import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; @@ -67,6 +76,7 @@ import type { interface WorkspaceSearch { terminalId?: string; chatSessionId?: string; + focusRequestId?: string; } export const Route = createFileRoute( @@ -77,6 +87,8 @@ 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, }), }); @@ -117,7 +129,7 @@ function getNodeAtPathInLayout( function V2WorkspacePage() { const { workspaceId } = Route.useParams(); - const { terminalId, chatSessionId } = Route.useSearch(); + const { terminalId, chatSessionId, focusRequestId } = Route.useSearch(); const collections = useCollections(); const { data: workspaces } = useLiveQuery( @@ -144,35 +156,75 @@ function V2WorkspacePage() { workspaceName={workspace.name} terminalId={terminalId} chatSessionId={chatSessionId} + focusRequestId={focusRequestId} /> ); } +/** + * 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 useClearActivePaneAttention({ + workspaceId, + store, +}: { + workspaceId: string; + store: StoreApi>; +}): void { + const activePane = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + return tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; + }); + const activePaneStatus = useV2PaneNotificationStatus(workspaceId, activePane); + const clearSourceAttention = useV2NotificationStore( + (state) => state.clearSourceAttention, + ); + + useEffect(() => { + if (activePaneStatus !== "review") return; + for (const source of getV2NotificationSourcesForPane(activePane)) { + clearSourceAttention(source, workspaceId); + } + }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); +} + function WorkspaceContent({ projectId, workspaceId, workspaceName, terminalId, chatSessionId, + focusRequestId, }: { projectId: string; workspaceId: string; workspaceName: string; terminalId?: string; chatSessionId?: string; + focusRequestId?: string; }) { const navigate = useNavigate(); const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ projectId, workspaceId, }); + useClearActivePaneAttention({ workspaceId, store }); const { matchedPresets, executePreset } = useV2PresetExecution({ store, workspaceId, projectId, }); useConsumePendingLaunch({ workspaceId, store }); - useConsumeAutomationRunLink({ store, terminalId, chatSessionId }); + useConsumeAutomationRunLink({ + store, + terminalId, + chatSessionId, + focusRequestId, + }); const collections = useCollections(); const rightSidebarOpenViewWidth = useRightSidebarOpenViewWidth(); const utils = electronTrpc.useUtils(); @@ -668,6 +720,12 @@ function WorkspaceContent({ paneActions={defaultPaneActions} contextMenuActions={defaultContextMenuActions} renderTabIcon={renderBrowserTabIcon} + renderTabAccessory={(tab) => ( + + )} renderBelowTabBar={() => ( + q + .from({ v2Workspaces: collections.v2Workspaces }) + .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 ( + <> + {hostGroups.map((group) => ( + + ))} + + ); +} + +function groupWorkspacesByHostUrl({ + workspaceHosts, + localWorkspaceRows, + machineId, + activeHostUrl, +}: { + workspaceHosts: WorkspaceHostRow[]; + localWorkspaceRows: Array<{ + workspaceId: string; + paneLayout: unknown; + }>; + machineId: string | null; + activeHostUrl: string | null; +}): HostNotificationSubscriberGroup[] { + 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/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx new file mode 100644 index 00000000000..f8e7cbdf85e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx @@ -0,0 +1,87 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +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 "../../lib/lifecycleEvents"; + +export interface HostNotificationWorkspaceState { + workspaceId: string; + paneLayout: WorkspaceState | null; +} + +export function HostNotificationSubscriber({ + hostUrl, + workspaces, +}: { + hostUrl: string; + workspaces: HostNotificationWorkspaceState[]; +}): null { + 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, + }); + }, + ); + + const handleTerminalLifecycle = useEffectEvent( + (workspaceId: string, payload: TerminalLifecyclePayload) => { + const workspace = workspacesById.get(workspaceId); + if (!workspace) return; + handleV2TerminalLifecycleEvent({ + workspaceId, + payload, + }); + }, + ); + + 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/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/components/V2NotificationController/lib/lifecycleEvents.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts new file mode 100644 index 00000000000..23fafcd9f70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -0,0 +1,187 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import { playRingtone } from "renderer/lib/ringtones/play"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +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 { + isV2NotificationTargetVisible, + resolveV2NotificationTarget, + type V2NotificationTarget, +} from "./resolveV2NotificationTarget"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +/** + * 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. + */ +export function handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout, + volume, + muted, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; + volume: number; + muted: boolean; +}): 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 }); +} + +export function handleV2TerminalLifecycleEvent({ + workspaceId, + payload, +}: { + workspaceId: string; + payload: TerminalLifecyclePayload; +}): void { + if (payload.eventType !== "exit") return; + clearSources(workspaceId, [ + getV2TerminalNotificationSource(payload.terminalId), + ]); +} + +/** + * 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 + * 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 { + const store = useV2NotificationStore.getState(); + const targetVisible = isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, + }); + const transition = resolveV2AgentStatusTransition({ + workspaceId, + payload, + statuses: store.sources, + targetVisible, + }); + + clearSources(workspaceId, transition.clearSources); + if (transition.setStatus) { + store.setSourceStatus( + transition.setStatus.source, + workspaceId, + transition.setStatus.status, + payload.occurredAt, + ); + } +} + +function getCurrentWorkspaceId(): string | null { + try { + // Matches both v1 `/workspace/` and v2 `/v2-workspace/` + // 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 { + return null; + } +} + +function shouldSuppress( + target: V2NotificationTarget, + paneLayout: WorkspaceState | null | undefined, +): boolean { + if (typeof document !== "undefined" && document.hidden) return false; + if (typeof window !== "undefined" && !document.hasFocus()) return false; + + return isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, + }); +} + +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"; + + void electronTrpcClient.notifications.showNative + .mutate({ + title, + body, + silent: true, + clickTarget: { + workspaceId, + source: { type: "terminal", id: target.terminalId }, + }, + }) + .catch((error) => { + console.warn( + "[notifications] failed to show native notification:", + error, + ); + }); +} + +function clearSources( + workspaceId: string, + sources: Array, +): void { + const store = useV2NotificationStore.getState(); + store.clearSourceStatuses( + sources.filter((source): source is V2NotificationSourceInput => + Boolean(source), + ), + workspaceId, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts new file mode 100644 index 00000000000..2dffbbd62e6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { + 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-terminal-hidden": { + id: "pane-terminal-hidden", + kind: "terminal", + data: { terminalId: "terminal-hidden" }, + }, + }, + }, + { + id: "tab-background", + createdAt: 2, + activePaneId: "pane-terminal-background", + layout: { type: "pane", paneId: "pane-terminal-background" }, + panes: { + "pane-terminal-background": { + id: "pane-terminal-background", + kind: "terminal", + data: { terminalId: "terminal-2" }, + }, + }, + }, + ], +}; + +function payload( + overrides: Partial, +): AgentLifecyclePayload { + return { + eventType: "Stop", + terminalId: "terminal-1", + 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", + terminalId: "terminal-1", + }); + }); + + it("falls back to a terminal-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, + 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({ terminalId: "terminal-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); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts new file mode 100644 index 00000000000..f520ed68270 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts @@ -0,0 +1,84 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +export interface V2NotificationTarget { + workspaceId: string; + tabId?: string; + paneId?: string; + terminalId: string; +} + +export function resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; +}): V2NotificationTarget { + return ( + resolveTerminalTarget({ + workspaceId, + terminalId: payload.terminalId, + paneLayout, + }) ?? { + workspaceId, + terminalId: payload.terminalId, + } + ); +} + +export function resolveTerminalTarget({ + workspaceId, + terminalId, + paneLayout, +}: { + workspaceId: string; + terminalId: string; + paneLayout: WorkspaceState | null | undefined; +}): 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, + 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 + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts new file mode 100644 index 00000000000..c00073f6814 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +const WORKSPACE_ID = "workspace-1"; + +function payload( + overrides: Partial, +): AgentLifecyclePayload { + return { + eventType: "Stop", + terminalId: "terminal-1", + occurredAt: 1, + ...overrides, + }; +} + +describe("resolveV2AgentStatusTransition", () => { + it("marks start as working on the terminal source", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Start", + terminalId: "terminal-1", + }), + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "working", + }, + }); + }); + + it("clears permission state on stop", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Stop", + terminalId: "terminal-1", + }), + statuses: { + "terminal:terminal-1": { + workspaceId: WORKSPACE_ID, + status: "permission", + }, + }, + targetVisible: false, + }), + ).toEqual({ + clearSources: [{ type: "terminal", id: "terminal-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" }), + statuses: {}, + targetVisible: true, + }), + ).toEqual({ + clearSources: [{ type: "terminal", id: "terminal-1" }], + setStatus: null, + }); + }); + + it("marks background stop as review", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", 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" }), + statuses: { + "terminal:terminal-1": { + workspaceId: "workspace-2", + status: "permission", + }, + }, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "review", + }, + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts new file mode 100644 index 00000000000..d6c9d614947 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts @@ -0,0 +1,59 @@ +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"; + +interface StatusEntry { + workspaceId: string; + status: PaneStatus; +} + +export interface V2AgentStatusTransition { + clearSources: V2NotificationSourceInput[]; + setStatus: { source: V2NotificationSource; status: ActivePaneStatus } | null; +} + +export function resolveV2AgentStatusTransition({ + workspaceId, + payload, + statuses, + targetVisible, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + statuses: Record; + targetVisible: boolean; +}): V2AgentStatusTransition { + const terminalSource = getV2TerminalNotificationSource(payload.terminalId); + const terminalSourceKey = getV2NotificationSourceKey(terminalSource); + + if (payload.eventType === "Start") { + return { + clearSources: [], + setStatus: { source: terminalSource, status: "working" }, + }; + } + + if (payload.eventType === "PermissionRequest") { + return { + clearSources: [], + setStatus: { source: terminalSource, status: "permission" }, + }; + } + + const entry = statuses[terminalSourceKey]; + const wasAwaitingPermission = + entry?.workspaceId === workspaceId && entry.status === "permission"; + if (wasAwaitingPermission || targetVisible) { + return { clearSources: [terminalSource], setStatus: null }; + } + + return { + clearSources: [], + setStatus: { source: terminalSource, status: "review" }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 9aac85bbfa4..fda0d4a21f1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -32,10 +32,12 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; +import { AgentHooks } from "./components/AgentHooks"; import { GlobalBrowserLifecycle } from "./components/GlobalBrowserLifecycle"; import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { MainWindowEffects } from "./components/MainWindowEffects"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; +import { V2NotificationController } from "./components/V2NotificationController"; import { WorktreeAutoSyncManager } from "./components/WorktreeAutoSyncManager"; import { createPierreWorker } from "./lib/pierreWorker"; import { CollectionsProvider } from "./providers/CollectionsProvider"; @@ -80,6 +82,29 @@ 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, + focusRequestId: crypto.randomUUID(), + } + : { + chatSessionId: source.id, + focusRequestId: crypto.randomUUID(), + }, + }); + return; + } + if ( event.type !== NOTIFICATION_EVENTS.TERMINAL_EXIT || !event.data?.paneId @@ -193,9 +218,11 @@ function AuthenticatedLayout() { poolOptions={{ workerFactory: createPierreWorker, poolSize: 8 }} highlighterOptions={{ preferredHighlighter: "shiki-wasm" }} > + + {isV2CloudEnabled ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 4d4cab8744f..de9c3ce296a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -305,8 +305,16 @@ export const Terminal = memo(function Terminal({ return () => clearTimeout(timeout); }, [connectionError, handleRetryConnection]); + const handleClearHotkey = useCallback(() => { + const xterm = xtermRef.current; + if (!xterm) return; + xterm.clear(); + clearScrollbackRef.current({ paneId }); + }, [paneId, clearScrollbackRef]); + const { isSearchOpen, setIsSearchOpen } = useTerminalHotkeys({ isFocused, + onClear: handleClearHotkey, xtermRef, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3410728fa02..b0db7772a5a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -8,12 +8,6 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; -import { - getBinding, - isTerminalReservedEvent, - matchesChord, - resolveHotkeyFromEvent, -} from "renderer/hotkeys"; import type { DetectedLink } from "renderer/lib/terminal/links"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; import { installRectangleRendererAlphaPatch } from "renderer/lib/terminal/webgl-vibrancy-patch"; @@ -24,10 +18,6 @@ import { DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; -import { - shouldBubbleClipboardShortcut, - shouldSelectAllShortcut, -} from "./clipboardShortcuts"; import { TERMINAL_OPTIONS } from "./config"; import { terminalRendererDebug } from "./debug"; import { suppressQueryResponses } from "./suppressQueryResponses"; @@ -264,27 +254,6 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { }; } -export interface ActiveSuggestionHandle { - suffix: string | null; - onAccept: () => void; - onExecute: () => void; - onDismiss: () => void; - selectNext?: () => void; - selectPrev?: () => void; - hasSuggestions?: boolean; -} - -export interface KeyboardHandlerOptions { - /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ - onShiftEnter?: () => void; - /** Callback for the configured clear terminal shortcut */ - onClear?: () => void; - onWrite?: (data: string) => void; - activeSuggestionRef?: { current: ActiveSuggestionHandle | null }; - canOpenSuggestions?: () => boolean; - onOpenSuggestions?: () => void; -} - /** * Setup copy handler for xterm to trim trailing whitespace from copied text. * @@ -329,248 +298,6 @@ export function setupCopyHandler(xterm: XTerm): () => void { }; } -/** - * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens - * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) - * - Clear terminal: Uses the configured clear shortcut - * - * Returns a cleanup function to remove the handler. - */ -export function setupKeyboardHandler( - xterm: XTerm, - options: KeyboardHandlerOptions = {}, -): () => void { - const platform = - typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; - const isMac = platform.includes("mac"); - const isWindows = platform.includes("win"); - - const handler = (event: KeyboardEvent): boolean => { - const noModifiers = - !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; - - if (event.key === "ArrowRight" && noModifiers && event.type === "keydown") { - const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.suffix) { - event.preventDefault(); - suggestion.onAccept(); - return false; - } - } - - if (event.key === "Enter" && noModifiers && event.type === "keydown") { - const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.hasSuggestions) { - event.preventDefault(); - suggestion.onExecute(); - return false; - } - } - - if (event.key === "ArrowDown" && noModifiers && event.type === "keydown") { - const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.hasSuggestions) { - event.preventDefault(); - suggestion.selectNext?.(); - return false; - } - if (options.canOpenSuggestions?.()) { - event.preventDefault(); - options.onOpenSuggestions?.(); - return false; - } - } - - if (event.key === "ArrowUp" && noModifiers && event.type === "keydown") { - const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.hasSuggestions) { - event.preventDefault(); - suggestion.selectPrev?.(); - return false; - } - if (options.canOpenSuggestions?.()) { - event.preventDefault(); - options.onOpenSuggestions?.(); - return false; - } - } - - if (noModifiers && (event.key === "ArrowLeft" || event.key === "Escape")) { - options.activeSuggestionRef?.current?.onDismiss(); - } - - const isShiftEnter = - event.key === "Enter" && - event.shiftKey && - !event.metaKey && - !event.ctrlKey && - !event.altKey; - - if (isShiftEnter) { - if (event.type === "keydown" && options.onShiftEnter) { - event.preventDefault(); - options.onShiftEnter(); - } - return false; - } - - const isCmdBackspace = - event.key === "Backspace" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdBackspace) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x15\x1b[D"); // Ctrl+U + left arrow - } - return false; - } - - // Cmd+Left: Move cursor to beginning of line (sends Ctrl+A) - const isCmdLeft = - event.key === "ArrowLeft" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdLeft) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x01"); // Ctrl+A - beginning of line - } - return false; - } - - // Cmd+Right: Move cursor to end of line (sends Ctrl+E) - const isCmdRight = - event.key === "ArrowRight" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdRight) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x05"); // Ctrl+E - end of line - } - return false; - } - - // Option+Left/Right (macOS): word navigation (Meta+B / Meta+F) - const isOptionLeft = - event.key === "ArrowLeft" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - // Option+Right: Move cursor forward by word (Meta+F) - const isOptionRight = - event.key === "ArrowRight" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - - // Ctrl+Left/Right (Windows): word navigation (Meta+B / Meta+F) - const isCtrlLeft = - event.key === "ArrowLeft" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - const isCtrlRight = - event.key === "ArrowRight" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - - if (shouldSelectAllShortcut(event, isMac)) { - if (event.type === "keydown") { - event.preventDefault(); - xterm.selectAll(); - } - return false; - } - - // Mirror VS Code terminal clipboard bindings so host copy/paste happens - // before kitty CSI-u handling in xterm consumes the command chord. - if ( - shouldBubbleClipboardShortcut(event, { - isMac, - isWindows, - hasSelection: xterm.hasSelection(), - }) - ) { - return false; - } - - // Terminal-reserved chords (ctrl+c/d/z/s/q) always go to xterm - if (isTerminalReservedEvent(event)) return true; - - // CLEAR_TERMINAL is handled here (xterm needs to call onClear) - const clearKeys = getBinding("CLEAR_TERMINAL"); - if (clearKeys && matchesChord(event, clearKeys)) { - if (event.type === "keydown" && options.onClear) { - options.onClear(); - } - return false; - } - - // Only bubble chords registered as app hotkeys; everything else reaches the PTY. - // Mirrors v2 terminal-runtime.ts:21 (VSCode terminalInstance pattern). - if (resolveHotkeyFromEvent(event) !== null) return false; - - return true; - }; - - xterm.attachCustomKeyEventHandler(handler); - - return () => { - xterm.attachCustomKeyEventHandler(() => true); - }; -} - export function setupFocusListener( xterm: XTerm, onFocus: () => void, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts index e346daa62c0..bd1634e5aab 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts @@ -6,6 +6,7 @@ import { scrollToBottom } from "../utils"; export interface UseTerminalHotkeysOptions { isFocused: boolean; + onClear: () => void; xtermRef: MutableRefObject; } @@ -16,6 +17,7 @@ export interface UseTerminalHotkeysReturn { export function useTerminalHotkeys({ isFocused, + onClear, xtermRef, }: UseTerminalHotkeysOptions): UseTerminalHotkeysReturn { const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -39,6 +41,11 @@ export function useTerminalHotkeys({ preventDefault: true, }); + useHotkey("CLEAR_TERMINAL", onClear, { + enabled: isFocused, + preventDefault: true, + }); + useHotkey( "SCROLL_TO_BOTTOM", () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index af196eb37c3..a671bddadcd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -18,14 +18,16 @@ import { isCommandEchoed, sanitizeForTitle } from "../commandBuffer"; import { DEBUG_TERMINAL, FIRST_RENDER_RESTORE_FALLBACK_MS } from "../config"; import { logTerminalWrite, terminalRendererDebug } from "../debug"; import { - type ActiveSuggestionHandle, setupClickToMoveCursor, setupCopyHandler, setupFocusListener, - setupKeyboardHandler, } from "../helpers"; import { isPaneDestroyed } from "../pane-guards"; import { coldRestoreState, pendingDetaches } from "../state"; +import { + type ActiveSuggestionHandle, + setupKeyboardHandler, +} from "../terminalKeyboardHandler"; import type { CreateOrAttachMutate, CreateOrAttachResult, @@ -852,7 +854,6 @@ export function useTerminalLifecycle({ const cleanupKeyboard = setupKeyboardHandler(xterm, { onShiftEnter: () => handleWrite("\x1b\r"), - onClear: handleClear, onWrite: handleWrite, activeSuggestionRef, canOpenSuggestions: () => canOpenHistorySuggestionsRef.current(), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts index f8a810223c5..0d589d02e3c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import type { ActiveSuggestionHandle } from "../helpers"; +import type { ActiveSuggestionHandle } from "../terminalKeyboardHandler"; export interface TerminalHistorySuggestion { command: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts new file mode 100644 index 00000000000..64869ffdea4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts @@ -0,0 +1,166 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; +import { resolveHotkeyFromEvent } from "renderer/hotkeys"; +import { translateLineEditChord } from "renderer/lib/terminal/line-edit-translations"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; + +// FORK NOTE: ActiveSuggestionHandle and suggestion-related options were +// originally defined in helpers.ts and are now co-located here alongside +// setupKeyboardHandler (upstream #3724 migration). +export interface ActiveSuggestionHandle { + suffix: string | null; + onAccept: () => void; + onExecute: () => void; + onDismiss: () => void; + selectNext?: () => void; + selectPrev?: () => void; + hasSuggestions?: boolean; +} + +export interface KeyboardHandlerOptions { + /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ + onShiftEnter?: () => void; + onWrite?: (data: string) => void; + // FORK NOTE: shell history suggestion integration (PR #). + // Not present in upstream — these drive the TerminalSuggestion overlay. + // Follow-up: apply to v2 terminal if needed. + activeSuggestionRef?: { current: ActiveSuggestionHandle | null }; + canOpenSuggestions?: () => boolean; + onOpenSuggestions?: () => void; +} + +/** + * Setup keyboard handling for xterm including: + * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens + * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) + * - Line-edit chord translations (shared with v2 via translateLineEditChord) + * - Shell history suggestion navigation (fork-only) + * + * Returns a cleanup function to remove the handler. + */ +export function setupKeyboardHandler( + xterm: XTerm, + options: KeyboardHandlerOptions = {}, +): () => void { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + const isMac = platform.includes("mac"); + const isWindows = platform.includes("win"); + + const handler = (event: KeyboardEvent): boolean => { + // Match v2: registered app hotkeys must escape xterm before terminal + // translations or macOS Cmd bubbling can consume them. + if (resolveHotkeyFromEvent(event) !== null) return false; + + const noModifiers = + !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; + + // FORK NOTE: suggestion overlay key handling — intercepts arrow keys + // and Enter when a suggestion is active before the shell sees them. + if (event.key === "ArrowRight" && noModifiers && event.type === "keydown") { + const suggestion = options.activeSuggestionRef?.current; + if (suggestion?.suffix) { + event.preventDefault(); + suggestion.onAccept(); + return false; + } + } + + if (event.key === "Enter" && noModifiers && event.type === "keydown") { + const suggestion = options.activeSuggestionRef?.current; + if (suggestion?.hasSuggestions) { + event.preventDefault(); + suggestion.onExecute(); + return false; + } + } + + if (event.key === "ArrowDown" && noModifiers && event.type === "keydown") { + const suggestion = options.activeSuggestionRef?.current; + if (suggestion?.hasSuggestions) { + event.preventDefault(); + suggestion.selectNext?.(); + return false; + } + if (options.canOpenSuggestions?.()) { + event.preventDefault(); + options.onOpenSuggestions?.(); + return false; + } + } + + if (event.key === "ArrowUp" && noModifiers && event.type === "keydown") { + const suggestion = options.activeSuggestionRef?.current; + if (suggestion?.hasSuggestions) { + event.preventDefault(); + suggestion.selectPrev?.(); + return false; + } + if (options.canOpenSuggestions?.()) { + event.preventDefault(); + options.onOpenSuggestions?.(); + return false; + } + } + + if (noModifiers && (event.key === "ArrowLeft" || event.key === "Escape")) { + options.activeSuggestionRef?.current?.onDismiss(); + } + + const isShiftEnter = + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey; + + if (isShiftEnter) { + if (event.type === "keydown" && options.onShiftEnter) { + event.preventDefault(); + options.onShiftEnter(); + } + return false; + } + + const translation = translateLineEditChord(event, { isMac, isWindows }); + if (translation !== null) { + if (event.type === "keydown" && options.onWrite) { + event.preventDefault(); + options.onWrite(translation); + } + return false; + } + + if (shouldSelectAllShortcut(event, isMac)) { + if (event.type === "keydown") { + event.preventDefault(); + xterm.selectAll(); + } + return false; + } + + // Mirror VS Code terminal clipboard bindings so host copy/paste happens + // before kitty CSI-u handling in xterm consumes the command chord. + if ( + shouldBubbleClipboardShortcut(event, { + isMac, + isWindows, + hasSelection: xterm.hasSelection(), + }) + ) { + return false; + } + + // Default: let xterm process unhandled keys, including terminal-reserved + // chords like ctrl+c/d/z/s/q. + return true; + }; + + xterm.attachCustomKeyEventHandler(handler); + + return () => { + xterm.attachCustomKeyEventHandler(() => true); + }; +} diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index ab0ff5253af..7130677b41c 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -48,6 +48,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/renderer/stores/v2-notifications/index.ts b/apps/desktop/src/renderer/stores/v2-notifications/index.ts new file mode 100644 index 00000000000..bc268226195 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/index.ts @@ -0,0 +1,28 @@ +export { + getV2ChatNotificationSource, + getV2NotificationSourceKey, + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + getV2TerminalNotificationSource, + selectV2ChatNotificationStatus, + selectV2PaneNotificationStatus, + selectV2SourcesNotificationStatus, + selectV2TabNotificationStatus, + selectV2TerminalNotificationStatus, + selectV2WorkspaceNotificationStatus, + useV2ChatNotificationStatus, + useV2NotificationStore, + useV2PaneNotificationStatus, + 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 new file mode 100644 index 00000000000..56fbbbec0ec --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + selectV2ChatNotificationStatus, + selectV2PaneNotificationStatus, + selectV2SourcesNotificationStatus, + 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 typed notification sources", () => { + expect(getV2NotificationSourcesForPane(terminalPane)).toEqual([ + { type: "terminal", id: "terminal-1" }, + ]); + 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, 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( + "permission", + ); + expect(selectV2TabNotificationStatus("workspace-1", tab)(state)).toBe( + "permission", + ); + 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( + 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( + 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( + { type: "terminal", id: "terminal-1" }, + "workspace-1", + ); + store.clearSourceAttention( + { type: "terminal", id: "terminal-2" }, + "workspace-1", + ); + + const state = useV2NotificationStore.getState(); + 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 new file mode 100644 index 00000000000..288e3e80d26 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.ts @@ -0,0 +1,365 @@ +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 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; + setSourceStatus: ( + source: V2NotificationSource, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + setTerminalStatus: ( + terminalId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + setChatStatus: ( + chatId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + clearSourceStatus: ( + source: V2NotificationSourceInput, + workspaceId?: string, + ) => void; + clearSourceStatuses: ( + sources: Iterable, + workspaceId?: string, + ) => void; + clearSourceAttention: ( + source: V2NotificationSourceInput, + workspaceId?: string, + ) => void; + clearWorkspaceStatuses: (workspaceId: string) => void; + clearWorkspaceAttention: (workspaceId: string) => void; +} + +export const useV2NotificationStore = create()((set) => ({ + sources: {}, + setSourceStatus: (source, workspaceId, status, occurredAt = Date.now()) => { + const sourceKey = getV2NotificationSourceKey(source); + set((state) => ({ + sources: { + ...state.sources, + [sourceKey]: { + sourceKey, + source, + workspaceId, + status, + occurredAt, + }, + }, + })); + }, + 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 entry = state.sources[sourceKey]; + if (!entry || (workspaceId && entry.workspaceId !== workspaceId)) { + return state; + } + const { [sourceKey]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearSourceStatuses: (sourceInputs, workspaceId) => { + set((state) => { + const sourceKeys = new Set( + [...sourceInputs].map(getV2NotificationSourceKey), + ); + const sources: Record = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if ( + sourceKeys.has(sourceKey as V2NotificationSourceKey) && + (!workspaceId || source.workspaceId === workspaceId) + ) { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, + clearSourceAttention: (source, workspaceId) => { + const sourceKey = getV2NotificationSourceKey(source); + set((state) => { + const entry = state.sources[sourceKey]; + if ( + !entry || + entry.status !== "review" || + (workspaceId && entry.workspaceId !== workspaceId) + ) { + return state; + } + const { [sourceKey]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearWorkspaceStatuses: (workspaceId) => { + set((state) => { + const sources: Record = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId) { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, + clearWorkspaceAttention: (workspaceId) => { + set((state) => { + const sources: Record = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId && source.status === "review") { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, +})); + +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, +): V2NotificationSource[] { + const terminalId = getTerminalIdForPane(pane); + if (terminalId) return [getV2TerminalNotificationSource(terminalId)]; + const chatId = getChatIdForPane(pane); + if (chatId) return [getV2ChatNotificationSource(chatId)]; + return []; +} + +export function getV2NotificationSourcesForTab( + tab: V2NotificationTabLike | null | undefined, +): V2NotificationSource[] { + if (!tab) return []; + const sources = new Map(); + for (const pane of Object.values(tab.panes)) { + for (const source of getV2NotificationSourcesForPane(pane)) { + sources.set(getV2NotificationSourceKey(source), source); + } + } + return [...sources.values()]; +} + +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, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForTab(tab), + ); +} + +export function selectV2PaneNotificationStatus( + workspaceId: string, + pane: V2NotificationPaneLike | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForPane(pane), + ); +} + +export function selectV2TerminalNotificationStatus( + workspaceId: string, + terminalId: string | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + terminalId ? [getV2TerminalNotificationSource(terminalId)] : [], + ); +} + +export function selectV2ChatNotificationStatus( + workspaceId: string, + chatId: string | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + chatId ? [getV2ChatNotificationSource(chatId)] : [], + ); +} + +export function selectV2SourcesNotificationStatus( + workspaceId: string, + sources: Iterable, +) { + const sourceKeys = [...new Set([...sources].map(getV2NotificationSourceKey))]; + return (state: V2NotificationState) => + selectStatusForSourceKeys(state, workspaceId, sourceKeys); +} + +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), + ); +} + +export function useV2ChatNotificationStatus( + workspaceId: string, + chatId: string | null | undefined, +) { + return useV2NotificationStore( + selectV2ChatNotificationStatus(workspaceId, chatId), + ); +} + +export function useV2SourcesNotificationStatus( + workspaceId: string, + sources: Iterable, +) { + return useV2NotificationStore( + selectV2SourcesNotificationStatus(workspaceId, sources), + ); +} + +function selectStatusForSourceKeys( + state: V2NotificationState, + workspaceId: string, + sourceKeys: Iterable, +) { + function* statuses() { + for (const sourceKey of sourceKeys) { + const source = state.sources[sourceKey]; + 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; +} + +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/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 9088a554ab8..0c66ddedcb9 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 6bf29f58450..8b17f74a3a4 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"; } + +export type V2NotificationSource = + | { type: "terminal"; id: string } + | { type: "chat"; id: string }; + +export interface V2NotificationSourceFocusTarget { + workspaceId: string; + source: V2NotificationSource; +} diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 3b53d7bd67f..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, }); @@ -118,6 +119,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..6dc3baff6e9 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -122,6 +122,32 @@ 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, "type">, + ): void { + 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 e64d91df2bd..983a1d8a2a2 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, @@ -7,4 +12,5 @@ export type { FsWatchCommand, GitChangedMessage, ServerMessage, + TerminalLifecycleMessage, } from "./types"; 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..190fd0662e8 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,24 @@ export interface GitChangedMessage { paths?: string[]; } +export interface AgentLifecycleMessage { + type: "agent:lifecycle"; + workspaceId: string; + eventType: AgentLifecycleEventType; + terminalId: string; + 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; @@ -28,6 +47,8 @@ export interface EventBusErrorMessage { export type ServerMessage = | FsEventsMessage | GitChangedMessage + | AgentLifecycleMessage + | TerminalLifecycleMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index 247a035f904..818615c0a35 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -112,6 +112,12 @@ interface BuildV2TerminalEnvParams { supersetEnv: "development" | "production"; agentHookPort: string; agentHookVersion: string; + /** + * 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; } /** @@ -135,6 +141,7 @@ export function buildV2TerminalEnv( supersetEnv, agentHookPort, agentHookVersion, + hostAgentHookUrl, } = params; // Defense in depth — baseEnv is pre-stripped at init, but strip again @@ -162,6 +169,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. 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; + } 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 cec378c0af0..c3c8c141855 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -12,6 +12,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, @@ -22,6 +23,7 @@ import { interface RegisterWorkspaceTerminalRouteOptions { app: Hono; db: HostDb; + eventBus: EventBus; upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; } @@ -31,6 +33,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 } @@ -298,6 +311,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; /** Hidden sessions are process-internal and should not appear in user pickers. */ @@ -309,6 +323,7 @@ export function createTerminalSessionInternal({ workspaceId, themeType, db, + eventBus, initialCommand, listed = true, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { @@ -357,6 +372,7 @@ 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(), }); let pty: IPty; @@ -462,6 +478,15 @@ export function createTerminalSessionInternal({ exitCode: session.exitCode, signal: session.exitSignal, }); + + eventBus?.broadcastTerminalLifecycle({ + workspaceId, + terminalId, + eventType: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + occurredAt: Date.now(), + }); }); if (initialCommand) { @@ -481,6 +506,7 @@ export function createTerminalSessionInternal({ export function registerWorkspaceTerminalRoute({ app, db, + eventBus, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { app.post("/terminal/sessions", async (c) => { @@ -499,6 +525,7 @@ export function registerWorkspaceTerminalRoute({ workspaceId: body.workspaceId, themeType: parseThemeType(body.themeType), db, + eventBus, }); if ("error" in result) { @@ -565,6 +592,7 @@ export function registerWorkspaceTerminalRoute({ workspaceId, themeType, db, + eventBus, }); if ("error" in result) { 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.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 new file mode 100644 index 00000000000..e231ad7e24c --- /dev/null +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -0,0 +1,60 @@ +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { terminalSessions } from "../../../db/schema"; +import { mapEventType } from "../../../events"; +import { publicProcedure, router } from "../../index"; + +/** + * 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({ + terminalId: z.string().optional(), + eventType: 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, 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 + * 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: publicProcedure.input(hookInput).mutation(async ({ ctx, input }) => { + const eventType = mapEventType(input.eventType); + if (!eventType) { + return { success: true, ignored: true as const }; + } + + 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: terminalSession.originWorkspaceId, + eventType, + terminalId: input.terminalId, + 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/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 823ceda946e..a965f7620c8 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -26,6 +26,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/shared/setup-terminal.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts index 0ec55253470..314de40170e 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts @@ -44,6 +44,7 @@ export function startSetupTerminalIfPresent(args: { terminalId, workspaceId: args.workspaceId, db: args.ctx.db, + eventBus: args.ctx.eventBus, initialCommand: candidate.buildCommand(setupScriptPath), }); if ("error" in result) { 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..3509a7b8af8 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -1,9 +1,11 @@ export { useEventBus } from "./hooks/useEventBus"; export { useGitChangeEvents } from "./hooks/useGitChangeEvents"; export { + type AgentLifecyclePayload, 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 b82cb6df283..24cef2ee840 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -1,10 +1,15 @@ 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" + | "terminal:lifecycle"; interface FsEventsPayload { events: FsWatchEvent[]; @@ -18,11 +23,29 @@ export interface GitChangedPayload { paths?: string[]; } +export interface AgentLifecyclePayload { + eventType: AgentLifecycleEventType; + terminalId: string; + 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 - : never; + : T extends "agent:lifecycle" + ? (workspaceId: string, payload: AgentLifecyclePayload) => void + : T extends "terminal:lifecycle" + ? (workspaceId: string, payload: TerminalLifecyclePayload) => void + : never; interface ListenerEntry { type: EventType; @@ -75,7 +98,10 @@ 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.type === "terminal:lifecycle" ? message.workspaceId : null; @@ -95,6 +121,26 @@ 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, + terminalId: message.terminalId, + 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 new file mode 100644 index 00000000000..7f604374a4a --- /dev/null +++ b/plans/20260422-v2-notification-hooks-client-side.md @@ -0,0 +1,578 @@ +# V2 Notification Hooks: Client-Side Playback Design + +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. + +## Executive Summary + +Agent notification UX should be owned by the client, not by Electron main and not by the host-service. + +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. + +This is the right split because the client is the only layer that knows: + +- 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 + +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. + +## Goals + +- 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 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 + - 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. + +## 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 playback. + +## 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 notification store + renderer suppresses or plays ringtone + renderer asks Electron main to show a silent native 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/V2NotificationController` + - mounts one host notification subscriber per host-service URL + - 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` + - 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 + - Electron-main playback for imported custom ringtone files + +This was the right first move, but it should not be the final architecture. + +## Current V2 Gaps + +The current implementation is useful but incomplete. + +- **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 + +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 v2 hook payload: + +- `terminalId`: stable runtime identity for terminal-backed agents +- `eventType`: raw agent lifecycle event name + +`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 + +The host-service endpoint should be an ingest endpoint, not a notification manager. + +Responsibilities: + +- accept the hook payload +- reject oversized or malformed input +- require `terminalId` +- derive `workspaceId` from the terminal session table +- 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; + terminalId: 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 }` +- 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` | +| 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 +permission > working > review > idle +``` + +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 + +Suppression should be target-based, not workspace-based. + +Correct behavior: + +- 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. + +This differs from the shipped fallback, which suppresses by current workspace when `paneId` and `tabId` are absent. + +## Notification Click Behavior + +Click handling is part of parity and should not be optional. + +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` 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 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 + +The notification controller should depend on a preference provider, not directly on `electronTrpc`. + +Desktop phase: + +- read existing local-db settings through Electron tRPC +- keep built-in renderer playback +- ask Electron main to play imported custom ringtone files so local paths are not exposed to the renderer + +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 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 + +Previous topology: + +```text +authenticated layout + V2NotificationController + one HostNotificationSubscriber per host URL + eventBus.on("agent:lifecycle", "*") + eventBus.on("terminal:lifecycle", "*") +``` + +Current topology: + +```text +authenticated layout + V2NotificationController + group open/known workspaces by host URL + one HostNotificationSubscriber per host URL + eventBus.on("agent:lifecycle", "*") + eventBus.on("terminal:lifecycle", "*") + resolve event workspace and typed source locally +``` + +Benefits: + +- one subscription path per host +- one set of notification settings reads +- one place for click handling and suppression +- easier web reuse +- easier tests + +The event bus already supports `workspaceId: "*"`, so this is mostly a client refactor. + +## Testing Plan + +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 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. + +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` 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. +- imported custom ringtone playback goes through Electron main, while built-in ringtone playback stays in the renderer. + +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` before falling back. + +### Phase 2: Refactor Ownership + +- Replace per-workspace listeners with per-host notification controllers. +- 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. + +### 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 +``` + +When this moves to web, create a web platform adapter rather than forking the lifecycle logic. + +## Acceptance Criteria + +This design is done when: + +- 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. + +## Security Rule For Future Changes + +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.