From 56cda6d1cd454e67a573f303af0267aa469efaea Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Thu, 30 Oct 2025 10:10:29 -0400 Subject: [PATCH] OS-level workflow run alert (w sound) on status change (#3862) --- .../src/routes/workflows/WorkflowRun.tsx | 14 +- .../workflows/hooks/useWorkflowRunQuery.ts | 2 + .../workflowRun/WorkflowRunStatusAlert.tsx | 267 ++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStatusAlert.tsx diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 19398b0d..bbf106ec 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -47,6 +47,7 @@ import { type ApiCommandOptions } from "@/util/apiCommands"; import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery"; import { constructCacheKeyValue } from "@/routes/workflows/editor/utils"; import { useCacheKeyValuesQuery } from "@/routes/workflows/hooks/useCacheKeyValuesQuery"; +import { WorkflowRunStatusAlert } from "@/routes/workflows/workflowRun/WorkflowRunStatusAlert"; function WorkflowRun() { const [searchParams, setSearchParams] = useSearchParams(); @@ -460,7 +461,18 @@ function WorkflowRun() { )} {workflowFailureReason} - {!isEmbedded && } + {!isEmbedded && ( +
+ + {workflowRun && ( + + )} +
+ )}
diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts index 5fad7d95..dba94835 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts @@ -40,6 +40,8 @@ function useWorkflowRunQuery() { } return false; }, + // required for OS-level notifications to work (workflow run completion) + refetchIntervalInBackground: true, placeholderData: keepPreviousData, refetchOnMount: (query) => { if (!query.state.data) { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStatusAlert.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStatusAlert.tsx new file mode 100644 index 00000000..f6166efd --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStatusAlert.tsx @@ -0,0 +1,267 @@ +import { + BellIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { type Status as WorkflowRunStatus } from "@/api/types"; + +const deadStatuses = [ + "canceled", + "completed", + "failed", + "terminated", + "timed_out", +] as const; + +const liveStatuses = ["paused", "running"] as const; + +type EndingStatus = (typeof deadStatuses)[number]; +type LiveStatus = (typeof liveStatuses)[number]; +type WatchableStatus = EndingStatus | LiveStatus; + +interface Props { + status: WorkflowRunStatus; + title?: string; + visible: boolean; +} + +function WorkflowRunStatusAlert({ status, title, visible }: Props) { + const [notifyIsOpen, setNotifyIsOpen] = useState(false); + const [statusesWatched, setStatusesWatched] = useState>( + new Set(), + ); + const [hasRequestedNotification, setHasRequestedNotification] = useState( + statusesWatched.size > 0, + ); + + const dropdownRef = useRef(null); + + const hasAllEndingStatuses = deadStatuses.every((s) => + statusesWatched.has(s), + ); + + // Handle click outside to close dropdown + useEffect(() => { + if (!notifyIsOpen) { + return; + } + + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setNotifyIsOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [notifyIsOpen]); + + // Handle ESC key to close dropdown + useEffect(() => { + if (!notifyIsOpen) { + return; + } + + function handleEscKey(event: KeyboardEvent) { + if (event.key === "Escape") { + setNotifyIsOpen(false); + } + } + + document.addEventListener("keydown", handleEscKey); + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [notifyIsOpen]); + + useEffect(() => { + if (!hasRequestedNotification) { + return; + } + + if (!statusesWatched.has(status as WatchableStatus)) { + return; + } + + const audio = new Audio("/dragon-cry.mp3"); + + audio.play().catch((error) => { + console.error("Failed to play notification sound:", error); + }); + + if (Notification.permission === "granted") { + try { + const notification = new Notification( + `Workflow Run Status Change: ${status}`, + { + body: `The workflow run "${title ?? "unknown"}" has changed to status: ${status}`, + icon: "/favicon.png", + tag: `workflow-${title ?? "unknown"}-${status}`, + requireInteraction: false, + }, + ); + + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } catch (error) { + console.error("Failed to create notification:", error); + } + } else { + console.warn( + "Notification permission not granted:", + Notification.permission, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + function askForPermissions() { + if (hasRequestedNotification) { + return; + } + + if (Notification.permission === "default") { + Notification.requestPermission().then((permission) => { + if (permission === "granted") { + setHasRequestedNotification(true); + } + }); + } else if (Notification.permission === "granted") { + setHasRequestedNotification(true); + } + } + + const notifySuffix = + statusesWatched.size === 0 + ? null + : statusesWatched.size === 1 + ? `(${(Array.from(statusesWatched)[0] ?? "").replace("_", " ")})` + : statusesWatched.size === deadStatuses.length && + deadStatuses.every((s) => statusesWatched.has(s)) + ? "(ending)" + : statusesWatched.size === liveStatuses.length + deadStatuses.length + ? "(any)" + : `(${statusesWatched.size} statuses)`; + + if (!visible) { + return null; + } + + return ( +
+
+ + +
+
+ ); +} + +export { WorkflowRunStatusAlert };