Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"lz-string": "^1.5.0",
"marked": "^15.0.12",
"mermaid": "^11.12.3",
"partysocket": "1.1.10",
"partysocket": "1.1.13",
"path-to-regexp": "^8.4.0",
"plotly.js": "^3.3.1",
"pyodide": "0.27.7",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/editor/app-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ interface Props {
connection: ConnectionStatus;
isRunning: boolean;
width: AppConfig["width"];
onReconnect?: () => void;
}

export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
width,
connection,
isRunning,
children,
onReconnect,
}) => {
const connectionState = connection.state;

return (
<>
<DynamicFavicon isRunning={isRunning} />
<StatusOverlay connection={connection} isRunning={isRunning} />
<StatusOverlay
connection={connection}
isRunning={isRunning}
onReconnect={onReconnect}
/>
<PyodideLoader>
<WrappedWithSidebar>
{/** oxlint-ignore-next-line -- ID is used by other components to grab the DOM element */}
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/components/editor/header/__tests__/status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/* Copyright 2026 Marimo. All rights reserved. */
// @vitest-environment jsdom

import { fireEvent, render } from "@testing-library/react";
import { createStore, Provider as JotaiProvider } from "jotai";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { viewStateAtom } from "@/core/mode";
import {
type ConnectionStatus,
WebSocketClosedReason,
WebSocketState,
} from "@/core/websocket/types";
import { StatusOverlay } from "../status";

function renderOverlay(
connection: ConnectionStatus,
onReconnect?: () => void,
): ReturnType<typeof render> {
const store = createStore();
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
<JotaiProvider store={store}>
<TooltipProvider>{children}</TooltipProvider>
</JotaiProvider>
);
return render(
<StatusOverlay
connection={connection}
isRunning={false}
onReconnect={onReconnect}
/>,
{ wrapper },
);
}

describe("StatusOverlay disconnect indicator", () => {
it("invokes onReconnect when the disconnect icon is clicked", () => {
const onReconnect = vi.fn();
const { getByTestId } = renderOverlay(
{
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
reason: "kernel not found",
},
onReconnect,
);

const icon = getByTestId("disconnected-indicator") as HTMLButtonElement;
expect(icon.tagName).toBe("BUTTON");
expect(icon.disabled).toBe(false);
expect(icon.getAttribute("aria-label")).toBe("Reconnect to app");
fireEvent.click(icon);
expect(onReconnect).toHaveBeenCalledTimes(1);
});

it("renders a disabled button when no onReconnect is provided", () => {
const { getByTestId } = renderOverlay({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
reason: "kernel not found",
});

const button = getByTestId("disconnected-indicator");
expect((button as HTMLButtonElement).disabled).toBe(true);
});

it.each([
[
WebSocketClosedReason.MALFORMED_QUERY,
"the kernel did not recognize a request; please file a bug with marimo",
],
[
WebSocketClosedReason.KERNEL_STARTUP_ERROR,
"Failed to start kernel sandbox",
],
])(
"renders a disabled button for non-recoverable close reason %s",
(code, reason) => {
const onReconnect = vi.fn();
const { getByTestId } = renderOverlay(
{ state: WebSocketState.CLOSED, code, reason },
onReconnect,
);

const button = getByTestId("disconnected-indicator") as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(onReconnect).not.toHaveBeenCalled();
},
);

it("does not render the disconnect icon when another tab has taken over", () => {
const onReconnect = vi.fn();
const { queryByTestId } = renderOverlay(
{
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.ALREADY_RUNNING,
reason: "another browser tab is already connected to the kernel",
canTakeover: true,
},
onReconnect,
);

expect(queryByTestId("disconnected-indicator")).toBeNull();
});
});
54 changes: 44 additions & 10 deletions frontend/src/components/editor/header/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ import { Tooltip } from "@/components/ui/tooltip";
import { notebookScrollToRunning } from "@/core/cells/actions";
import { onlyScratchpadIsRunningAtom } from "@/core/cells/cells";
import { viewStateAtom } from "@/core/mode";
import { type ConnectionStatus, WebSocketState } from "@/core/websocket/types";
import {
type ConnectionStatus,
WebSocketClosedReason,
WebSocketState,
} from "@/core/websocket/types";
import { cn } from "@/utils/cn";

export const StatusOverlay: React.FC<{
connection: ConnectionStatus;
isRunning: boolean;
}> = ({ connection, isRunning }) => {
onReconnect?: () => void;
}> = ({ connection, isRunning, onReconnect }) => {
const { mode } = useAtomValue(viewStateAtom);
const isClosed = connection.state === WebSocketState.CLOSED;
const isOpen = connection.state === WebSocketState.OPEN;
// Only KERNEL_DISCONNECTED is recoverable by a retry. Other terminal
// reasons (MALFORMED_QUERY, KERNEL_STARTUP_ERROR) would deterministically
// fail the same way; ALREADY_RUNNING is handled by `LockedIcon` below.
const canReconnect =
isClosed && connection.code === WebSocketClosedReason.KERNEL_DISCONNECTED;

return (
<>
Expand All @@ -28,7 +38,11 @@ export const StatusOverlay: React.FC<{
)}
>
{isOpen && isRunning && <RunningIcon />}
{isClosed && !connection.canTakeover && <DisconnectedIcon />}
{isClosed && !connection.canTakeover && (
<DisconnectedIcon
onReconnect={canReconnect ? onReconnect : undefined}
/>
)}
{isClosed && connection.canTakeover && <LockedIcon />}
</div>
</>
Expand All @@ -37,13 +51,33 @@ export const StatusOverlay: React.FC<{

const topLeftStatus = "print:hidden pointer-events-auto hover:cursor-pointer";

const DisconnectedIcon = () => (
<Tooltip content="App disconnected">
<div className={topLeftStatus}>
<UnlinkIcon className="w-[25px] h-[25px] text-(--red-11)" />
</div>
</Tooltip>
);
const DisconnectedIcon: React.FC<{ onReconnect?: () => void }> = ({
onReconnect,
}) => {
const disabled = !onReconnect;
return (
<Tooltip
content={
disabled ? "App disconnected" : "App disconnected β€” click to reconnect"
}
>
{/* Wrapper span keeps the tooltip reachable when the button is
disabled β€” a disabled <button> swallows pointer events. */}
<span tabIndex={disabled ? 0 : -1}>
<button
type="button"
className={cn(topLeftStatus, "bg-transparent border-0 p-0")}
aria-label={disabled ? "App disconnected" : "Reconnect to app"}
data-testid="disconnected-indicator"
onClick={onReconnect}
disabled={disabled}
>
<UnlinkIcon className="w-[25px] h-[25px] text-(--red-11)" />
</button>
</span>
</Tooltip>
);
};

const LockedIcon = () => (
<Tooltip content="Notebook locked">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/edit-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const EditApp: React.FC<AppProps> = ({
};
}, []);

const { connection } = useMarimoKernelConnection({
const { connection, reconnect } = useMarimoKernelConnection({
autoInstantiate: userConfig.runtime.auto_instantiate,
setCells: (cells, layout) => {
setCells(cells);
Expand Down Expand Up @@ -146,6 +146,7 @@ export const EditApp: React.FC<AppProps> = ({
connection={connection}
isRunning={isRunning}
width={appConfig.width}
onReconnect={reconnect}
>
<AppHeader
connection={connection}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/run-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
};
}, []);

const { connection } = useMarimoKernelConnection({
const { connection, reconnect } = useMarimoKernelConnection({
autoInstantiate: true,
setCells: setCells,
sessionId: getSessionId(),
Expand Down Expand Up @@ -77,6 +77,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
connection={connection}
isRunning={isRunning}
width={appConfig.width}
onReconnect={reconnect}
>
<AppHeader connection={connection} className="sm:pt-8">
{galleryHref && (
Expand Down
Loading
Loading