From d2c232cc5495d685f2b3a997406af99269855a19 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 17 Jun 2026 09:24:44 -0700 Subject: [PATCH] feat: add browser notifications for agent run completion (#1558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add browser notifications for agent run completion Request notification permission when a user starts their first agent run from the home page. A toggle in Profile Settings lets users enable/disable desktop notifications. When a run transitions from running to a terminal state (finished/error/interrupted), a browser notification is fired — suppressed for the thread the user is currently viewing. Co-authored-by: open-swe[bot] * fix: notify for active thread when page is in background tab Only suppress the notification for the active thread when the page is actually visible. If the user switched to another browser tab, the notification fires even for the thread they have open. Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- ui/src/components/agents/AgentsHome.tsx | 7 ++ ui/src/lib/agents/useRunCompletionNotifier.ts | 41 +++++++++++ ui/src/lib/notifications.ts | 62 ++++++++++++++++ ui/src/routes/agents.tsx | 5 ++ ui/src/routes/my-settings.tsx | 71 +++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 ui/src/lib/agents/useRunCompletionNotifier.ts create mode 100644 ui/src/lib/notifications.ts diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index 1fd4174a..9e84ef4a 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -16,6 +16,10 @@ import { } from "@/lib/agents/queries" import { useModelOptions } from "@/lib/agents/provider/useModelOptions" import { useProfile, useRepos } from "@/lib/profile" +import { + requestNotificationPermission, + setNotificationsPref, +} from "@/lib/notifications" function promptContent(text: string, images: Array) { const trimmed = text.trim() @@ -74,6 +78,9 @@ export function AgentsHome() { }, [stream.threadId, queryClient, navigate]) const handleSubmit = (prompt: string, images: Array) => { + void requestNotificationPermission().then((perm) => { + if (perm === "granted") setNotificationsPref(true) + }) draftRef.current = { prompt, images, diff --git a/ui/src/lib/agents/useRunCompletionNotifier.ts b/ui/src/lib/agents/useRunCompletionNotifier.ts new file mode 100644 index 00000000..fcf09653 --- /dev/null +++ b/ui/src/lib/agents/useRunCompletionNotifier.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react" + +import type { AgentThread } from "@/lib/agents/types" +import { showRunNotification } from "@/lib/notifications" + +const TERMINAL_STATUSES = new Set(["finished", "error", "interrupted"]) + +/** + * Watches the thread list for transitions from `running` to a terminal status + * and fires a browser notification for each run that completes. Suppresses + * notifications for the thread the user is currently viewing only when the + * page is visible — background tabs still notify even for the active thread. + */ +export function useRunCompletionNotifier( + threads: Array | undefined, + activeThreadId?: string +) { + const prevStatusRef = useRef>(new Map()) + + useEffect(() => { + if (!threads) return + const isViewingThread = + !!activeThreadId && document.visibilityState === "visible" + const prev = prevStatusRef.current + for (const thread of threads) { + const prevStatus = prev.get(thread.id) + if (prevStatus === undefined) { + prev.set(thread.id, thread.status) + continue + } + if ( + prevStatus === "running" && + TERMINAL_STATUSES.has(thread.status) && + !(isViewingThread && thread.id === activeThreadId) + ) { + showRunNotification(thread) + } + prev.set(thread.id, thread.status) + } + }, [threads, activeThreadId]) +} diff --git a/ui/src/lib/notifications.ts b/ui/src/lib/notifications.ts new file mode 100644 index 00000000..20edaaae --- /dev/null +++ b/ui/src/lib/notifications.ts @@ -0,0 +1,62 @@ +import type { AgentThread } from "./agents/types" + +export const NOTIFICATIONS_PREF_KEY = "open-swe-notifications-enabled" + +export function notificationsSupported(): boolean { + return typeof window !== "undefined" && "Notification" in window +} + +export function getNotificationPermission(): NotificationPermission | null { + if (!notificationsSupported()) return null + return Notification.permission +} + +export function notificationsEnabled(): boolean { + if (!notificationsSupported()) return false + if (Notification.permission !== "granted") return false + try { + return localStorage.getItem(NOTIFICATIONS_PREF_KEY) === "true" + } catch { + return false + } +} + +export async function requestNotificationPermission(): Promise { + if (!notificationsSupported()) return null + if (Notification.permission === "granted") return "granted" + if (Notification.permission === "denied") return "denied" + return Notification.requestPermission() +} + +export function setNotificationsPref(enabled: boolean) { + try { + localStorage.setItem(NOTIFICATIONS_PREF_KEY, String(enabled)) + } catch { + /* ignore */ + } +} + +export function showRunNotification(thread: AgentThread) { + if (!notificationsEnabled()) return + const statusLabel = + thread.status === "error" + ? "encountered an error" + : thread.status === "interrupted" + ? "was interrupted" + : "finished" + const title = thread.title || "Agent run" + const body = `Run ${statusLabel}.` + try { + const n = new Notification(title, { + body, + icon: "/logo-mark.png", + tag: `run-${thread.id}`, + }) + n.onclick = () => { + window.focus() + n.close() + } + } catch { + /* ignore */ + } +} diff --git a/ui/src/routes/agents.tsx b/ui/src/routes/agents.tsx index e93d3433..1f3e79d5 100644 --- a/ui/src/routes/agents.tsx +++ b/ui/src/routes/agents.tsx @@ -9,6 +9,8 @@ import { AgentsShell } from "@/components/agents/AgentsSidebar" import { Skeleton } from "@/components/ui/skeleton" import agentsCss from "@/styles/agents.css?url" import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider" +import { useAgentThreads } from "@/lib/agents/queries" +import { useRunCompletionNotifier } from "@/lib/agents/useRunCompletionNotifier" import { useSession } from "@/lib/session" export const Route = createFileRoute("/agents")({ @@ -33,6 +35,9 @@ function AgentsLayout() { ? threadId : undefined + const threadsQuery = useAgentThreads() + useRunCompletionNotifier(threadsQuery.data, activeThreadId) + if (session.isLoading) { return (
diff --git a/ui/src/routes/my-settings.tsx b/ui/src/routes/my-settings.tsx index 1c1e002e..1367bded 100644 --- a/ui/src/routes/my-settings.tsx +++ b/ui/src/routes/my-settings.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" import { api, slackConnectUrl } from "@/lib/api" import { buildProfileUpdate, @@ -22,6 +23,12 @@ import { useSaveProfile, } from "@/lib/profile" import { useSession } from "@/lib/session" +import { + notificationsEnabled, + notificationsSupported, + requestNotificationPermission, + setNotificationsPref, +} from "@/lib/notifications" import { cn } from "@/lib/utils" export const Route = createFileRoute("/my-settings")({ @@ -118,6 +125,68 @@ function UserMappingSection({ session }: { session: SessionUser }) { ) } +function NotificationsSection() { + const supported = notificationsSupported() + const [enabled, setEnabled] = useState(() => notificationsEnabled()) + const [permissionDenied, setPermissionDenied] = useState( + () => supported && Notification.permission === "denied" + ) + + const handleToggle = async (checked: boolean) => { + if (checked) { + const perm = await requestNotificationPermission() + if (perm === "granted") { + setNotificationsPref(true) + setEnabled(true) + } else if (perm === "denied") { + setPermissionDenied(true) + } + } else { + setNotificationsPref(false) + setEnabled(false) + } + } + + if (!supported) { + return ( + + + Not supported + + } + /> + + ) + } + + return ( + + + } + /> + + ) +} + function MySettingsPage() { const session = useSession() const qc = useQueryClient() @@ -216,6 +285,8 @@ function MySettingsPage() { /> + +