mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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:
parent
0b6806c4ba
commit
d2c232cc54
5 changed files with 186 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
41
ui/src/lib/agents/useRunCompletionNotifier.ts
Normal file
41
ui/src/lib/agents/useRunCompletionNotifier.ts
Normal 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])
|
||||
}
|
||||
62
ui/src/lib/notifications.ts
Normal file
62
ui/src/lib/notifications.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue