/* Copyright 2026 Marimo. All rights reserved. */ import { usePrevious } from "@dnd-kit/utilities"; import { useEffect } from "react"; import { useCellErrors } from "@/core/cells/cells"; import { useEventListener } from "@/hooks/useEventListener"; import successFaviconUrl from "../../assets/circle-check.ico"; import runningFaviconUrl from "../../assets/circle-play.ico"; import errorFaviconUrl from "../../assets/circle-x.ico"; const FAVICON_PATHS = { idle: "./favicon.ico", success: successFaviconUrl, running: runningFaviconUrl, error: errorFaviconUrl, } as const; // Cache favicon object URLs lazily type FaviconKey = keyof typeof FAVICON_PATHS; async function getFaviconUrl(key: FaviconKey): Promise { return FAVICON_PATHS[key]; } interface Props { isRunning: boolean; } function maybeSendNotification(numErrors: number) { if (document.visibilityState === "visible") { return; } const sendNotification = async () => { if (numErrors === 0) { new Notification("Execution completed", { body: "Your notebook run completed successfully.", icon: await getFaviconUrl("success"), }); } else { new Notification("Execution failed", { body: `Your notebook run encountered ${numErrors} error(s).`, icon: await getFaviconUrl("error"), }); } }; if (!("Notification" in window) || Notification.permission === "denied") { // Return } else if (Notification.permission === "granted") { sendNotification(); } else if (Notification.permission === "default") { // We need to ask the user for permission Notification.requestPermission().then((permission) => { // If the user accepts, let's create a notification if (permission === "granted") { sendNotification(); } }); } } export const DynamicFavicon = (props: Props) => { const { isRunning } = props; const errors = useCellErrors(); let favicon: HTMLLinkElement | null = document.querySelector("link[rel~='icon']"); if (!favicon) { favicon = document.createElement("link"); favicon.rel = "icon"; document.getElementsByTagName("head")[0].append(favicon); } useEffect(() => { // No change on startup (autorun enabled or not) // Treat the default marimo favicon as "idle" if (!isRunning && favicon.href.includes("favicon")) { return; } const updateFavicon = async () => { let key: FaviconKey; // When notebook is running, display running favicon if (isRunning) { key = "running"; } else { // When run is complete, display success or error favicon key = errors.length === 0 ? "success" : "error"; } favicon.href = await getFaviconUrl(key); // If notebook is in focus, reset favicon after 3 seconds // If not in focus, the focus event listener handles it if (!document.hasFocus()) { return; } const timeoutId = setTimeout(async () => { favicon.href = await getFaviconUrl("idle"); }, 3000); return () => clearTimeout(timeoutId); }; updateFavicon(); }, [isRunning, errors, favicon]); // Send user notification when run has completed const prevRunning = usePrevious(isRunning) ?? isRunning; useEffect(() => { if (prevRunning && !isRunning) { maybeSendNotification(errors.length); } }, [errors, prevRunning, isRunning]); // When notebook comes back in focus, reset favicon useEventListener(window, "focus", async (_) => { if (!isRunning) { favicon.href = await getFaviconUrl("idle"); } }); return null; };