feat: add browser notifications for agent run completion (#1558)

* 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] <open-swe@users.noreply.github.com>

* 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] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-17 09:24:44 -07:00 • committed by GitHub
parent 0b6806c4ba
commit d2c232cc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 186 additions and 0 deletions

View file

@ -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<ImageChunk>) {
const trimmed = text.trim()
@ -74,6 +78,9 @@ export function AgentsHome() {
}, [stream.threadId, queryClient, navigate])
const handleSubmit = (prompt: string, images: Array<ImageChunk>) => {
void requestNotificationPermission().then((perm) => {
if (perm === "granted") setNotificationsPref(true)
})
draftRef.current = {
prompt,
images,

View file

@ -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<AgentThread> | undefined,
activeThreadId?: string
) {
const prevStatusRef = useRef<Map<string, string>>(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])
}

View file

@ -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<NotificationPermission | null> {
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 */
}
}

View file

@ -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 (
<main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6">

View file

@ -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 (
<SettingsSection title="Notifications">
<SettingsRow
label="Desktop notifications"
description="Your browser does not support desktop notifications."
control={
<span className="text-[10px] text-muted-foreground">
Not supported
</span>
}
/>
</SettingsSection>
)
}
return (
<SettingsSection
title="Notifications"
description="Get a desktop notification when an agent run finishes."
>
<SettingsRow
label="Desktop notifications"
description={
permissionDenied
? "Permission was denied. Re-enable it in your browser's site settings."
: "Show a notification when a run completes."
}
control={
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={permissionDenied}
/>
}
/>
</SettingsSection>
)
}
function MySettingsPage() {
const session = useSession()
const qc = useQueryClient()
@ -216,6 +285,8 @@ function MySettingsPage() {
/>
</SettingsSection>
<NotificationsSection />
<SettingsSection title="Account">
<SettingsRow
label="Sign out"