OS-level workflow run alert (w sound) on status change (#3862)

This commit is contained in:
Jonathan Dobson
2025-10-30 10:10:29 -04:00
committed by GitHub
parent a87a255b7b
commit 56cda6d1cd
3 changed files with 282 additions and 1 deletions

View File

@@ -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() {
</div>
)}
{workflowFailureReason}
{!isEmbedded && <SwitchBarNavigation options={switchBarOptions} />}
{!isEmbedded && (
<div className="flex items-center justify-between">
<SwitchBarNavigation options={switchBarOptions} />
{workflowRun && (
<WorkflowRunStatusAlert
status={workflowRun.status}
title={workflow?.title}
visible={workflowRun && !isFinalized}
/>
)}
</div>
)}
<div className="flex h-[42rem] gap-6">
<div className="w-2/3">
<Outlet />

View File

@@ -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) {

View File

@@ -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<Set<WatchableStatus>>(
new Set(),
);
const [hasRequestedNotification, setHasRequestedNotification] = useState(
statusesWatched.size > 0,
);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div ref={dropdownRef} className="relative inline-block">
<div className="flex items-center gap-2">
<Button
className="relative"
variant="outline"
onClick={() => {
askForPermissions();
setNotifyIsOpen(!notifyIsOpen);
}}
>
<BellIcon className="mr-2 h-4 w-4" />
{notifySuffix ? `Notify ${notifySuffix}` : "Notify"}
{notifyIsOpen ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)}
{notifyIsOpen && (
<div
className="absolute right-0 top-full z-10 mt-2 w-48 rounded-md border bg-background shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-1 p-2">
{liveStatuses.map((s) => (
<div key={s} className="flex items-center gap-2 p-2 text-sm">
<Checkbox
id={s}
checked={statusesWatched.has(s)}
onCheckedChange={(checked) => {
setStatusesWatched((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(s);
} else {
newSet.delete(s);
}
return newSet;
});
}}
/>
<label htmlFor={s} className="cursor-pointer">
{s}
</label>
</div>
))}
</div>
<div className="mt-2 space-y-1 border-t bg-slate-elevation3 p-2">
<div
key="any-ending"
className="flex items-center gap-2 p-2 text-sm"
>
<Checkbox
id="any-ending"
checked={hasAllEndingStatuses}
onCheckedChange={(checked) => {
setStatusesWatched((prev) => {
const newSet = new Set(prev);
if (checked) {
deadStatuses.forEach((s) => newSet.add(s));
} else if (!checked) {
deadStatuses.forEach((s) => newSet.delete(s));
}
return newSet;
});
}}
/>
<label htmlFor="any-ending" className="cursor-pointer">
all ending
</label>
</div>
{deadStatuses.map((s) => (
<div key={s} className="flex items-center gap-2 p-2 text-sm">
<Checkbox
id={s}
checked={statusesWatched.has(s)}
onCheckedChange={(checked) => {
setStatusesWatched((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(s);
} else {
newSet.delete(s);
}
return newSet;
});
}}
/>
<label htmlFor={s} className="cursor-pointer">
{s.replace("_", " ")}
</label>
</div>
))}
</div>
</div>
)}
</Button>
<HelpTooltip content="When this workflow changes to a particular status, notify me via OS notifications and a sound." />
</div>
</div>
);
}
export { WorkflowRunStatusAlert };