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() { /> + +