Skip to content

Commit

Permalink
feat: Feedback on notebook run completion (#1634)
Browse files Browse the repository at this point in the history
* improvement: Dynamic favicon for run feedback

* fix: Define effect in app container, not each cell

* chore: Lint, typecheck

* fix: Include icons in assets endpoints

* feat, refactor: DynamicFavicon component, browser notifications

* fix: Only reset on focus if run completed

* fix: No favicon change on startup, notification on run completion
  • Loading branch information
wasimsandhu authored Jun 19, 2024
1 parent 0fd7a5c commit c73db36
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 1 deletion.
Binary file added frontend/public/circle-check.ico
Binary file not shown.
Binary file added frontend/public/circle-play.ico
Binary file not shown.
Binary file added frontend/public/circle-x.ico
Binary file not shown.
3 changes: 2 additions & 1 deletion frontend/src/components/editor/app-container.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { WebSocketState } from "@/core/websocket/types";
import { cn } from "@/utils/cn";
import React, { PropsWithChildren } from "react";
import { StatusOverlay } from "./header/status";
import { AppConfig } from "@/core/config/config-schema";
import { WrappedWithSidebar } from "./renderers/vertical-layout/sidebar/wrapped-with-sidebar";
import { PyodideLoader } from "@/core/pyodide/PyodideLoader";
import { DynamicFavicon } from "./dynamic-favicon";

interface Props {
connectionState: WebSocketState;
Expand All @@ -22,6 +22,7 @@ export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
}) => {
return (
<>
<DynamicFavicon isRunning={isRunning} />
<StatusOverlay state={connectionState} isRunning={isRunning} />
<PyodideLoader>
<WrappedWithSidebar>
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/components/editor/dynamic-favicon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { useCellErrors } from "@/core/cells/cells";
import { useEventListener } from "@/hooks/useEventListener";
import { usePrevious } from "@dnd-kit/utilities";
import { useEffect } from "react";

const FAVICONS = {
idle: "./favicon.ico",
success: "./circle-check.ico",
running: "./circle-play.ico",
error: "./circle-x.ico",
};

interface Props {
isRunning: boolean;
}

function maybeSendNotification(numErrors: number) {
if (document.visibilityState === "visible") {
return;
}

const sendNotification = () => {
if (numErrors === 0) {
new Notification("Execution completed", {
body: "Your notebook run completed successfully.",
icon: FAVICONS.success,
});
} else {
new Notification("Execution failed", {
body: `Your notebook run encountered ${numErrors} error(s).`,
icon: FAVICONS.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;
}
// When notebook is running, display running favicon
if (isRunning) {
favicon.href = FAVICONS.running;
return;
}
// When run is complete, display success or error favicon
favicon.href = errors.length === 0 ? FAVICONS.success : FAVICONS.error;
// 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(() => {
favicon.href = FAVICONS.idle;
}, 3000);

return () => {
favicon.href = FAVICONS.idle;
clearTimeout(timeoutId);
};
}, [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", (_) => {
if (!isRunning) {
favicon.href = FAVICONS.idle;
}
});

return null;
};
3 changes: 3 additions & 0 deletions marimo/_server/api/endpoints/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ async def index(request: Request) -> HTMLResponse:

STATIC_FILES = [
r"(favicon\.ico)",
r"(circle-check\.ico)",
r"(circle-play\.ico)",
r"(circle-x\.ico)",
r"(manifest\.json)",
r"(android-chrome-(192x192|512x512)\.png)",
r"(apple-touch-icon\.png)",
Expand Down

0 comments on commit c73db36

Please sign in to comment.