feat(frontend/library): Show toast on WebSocket (dis|re)connect (#9949)

- Resolves #9941
- Follow-up to #9935

### Changes 🏗️

- Show toast when WS connection (dis|re)connects (on `/library/agents/[id]`)
  - Implement `BackendAPI.onWebSocketDisconnect`

Related improvements:
- Clean up WebSocket state management & logging in `BackendAPI`
- Clean up & split loading spinner implementation: `Spinner` -> `LoadingBox` + `LoadingSpinner`

Also, unrelated:
- fix(frontend/library): Add 2 second debounce to page refresh logic
  This eliminates 3 triple API calls (so 9 -> 3 total) on page load: `GET /library/agents/{agent_id}`, `GET /graphs/{graph_id}/executions`, and `GET /graphs/{graph_id}/executions/{exec_id}`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - Start the frontend and backend applications (locally)
  - Navigate to `/library/agents/[id]`
  - Kill the backend
  - [x] -> a toast should appear "Connection to server was lost"
  - [x] -> this toast should be shown as long as the server is down
  - Re-start the backend
  - [x] -> toast should change to show "Connection re-established"
  - [x] -> toast should now disappear after 2 seconds

---

Co-authored-by: Krzysztof Czerwinski <kpczerwinski@gmail.com>
This commit is contained in:
Reinier van der Leer
2025-05-19 23:17:06 +02:00
committed by GitHub
parent ac532ca4b9
commit 0a79e1c5fd
11 changed files with 140 additions and 40 deletions

View File

@@ -39,9 +39,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { LoadingSpinner } from "@/components/ui/loading";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
@@ -69,7 +71,6 @@ export default function AgentRunsPage(): React.ReactElement {
const { state: onboardingState, updateState: updateOnboardingState } =
useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const { toast } = useToast();
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -120,7 +121,11 @@ export default function AgentRunsPage(): React.ReactElement {
}
}, [selectedRun, onboardingState, updateOnboardingState]);
const lastRefresh = useRef<number>(0);
const refreshPageData = useCallback(() => {
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
lastRefresh.current = Date.now();
api.getLibraryAgent(agentID).then((agent) => {
setAgent(agent);
@@ -156,6 +161,44 @@ export default function AgentRunsPage(): React.ReactElement {
// Initial load
useEffect(() => {
refreshPageData();
// Show a toast when the WebSocket connection disconnects
let connectionToast: ReturnType<typeof toast> | null = null;
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
connectionToast ??= toast({
title: "Connection to server was lost",
variant: "destructive",
description: (
<div className="flex items-center">
Trying to reconnect...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: Infinity, // show until connection is re-established
dismissable: false,
});
});
const cancelConnectHandler = api.onWebSocketConnect(() => {
if (connectionToast)
connectionToast.update({
id: connectionToast.id,
title: "✅ Connection re-established",
variant: "default",
description: (
<div className="flex items-center">
Refreshing data...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: 2000,
dismissable: true,
});
connectionToast = null;
});
return () => {
cancelDisconnectHandler();
cancelConnectHandler();
};
}, []);
// Subscribe to WebSocket updates for agent runs

View File

@@ -16,7 +16,7 @@ import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -98,7 +98,7 @@ export default function LoginPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -27,7 +27,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
export default function PrivatePage() {
const { supabase, user, isUserLoading } = useSupabase();
@@ -123,7 +123,7 @@ export default function PrivatePage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!user || !supabase) {

View File

@@ -24,7 +24,7 @@ import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import { getBehaveAs } from "@/lib/utils";
import { useTurnstile } from "@/hooks/useTurnstile";
@@ -134,7 +134,7 @@ export default function ResetPasswordPage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -18,7 +18,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -94,7 +94,7 @@ export default function SignupPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -1,11 +0,0 @@
import { LoaderCircle } from "lucide-react";
export default function Spinner({ className }: { className?: string }) {
const spinnerClasses = `mr-2 h-16 w-16 animate-spin ${className || ""}`;
return (
<div className="flex items-center justify-center">
<LoaderCircle className={spinnerClasses} />
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { LoadingSpinner } from "@/components/ui/loading";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import Spinner from "../Spinner";
const variants = {
default: "bg-zinc-700 hover:bg-zinc-800",
@@ -55,7 +55,7 @@ export default function OnboardingButton({
if (href && !disabled) {
return (
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</Link>
@@ -68,7 +68,7 @@ export default function OnboardingButton({
disabled={disabled}
className={buttonClasses}
>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</button>

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import { LoaderCircle } from "lucide-react";
export default function LoadingBox({
className,
spinnerSize,
}: {
className?: string;
spinnerSize?: string | number;
}) {
const spinnerSizeClass =
typeof spinnerSize == "string"
? `size-[${spinnerSize}]`
: typeof spinnerSize == "number"
? `size-${spinnerSize}`
: undefined;
return (
<div className={cn("flex items-center justify-center", className)}>
<LoadingSpinner className={spinnerSizeClass} />
</div>
);
}
export function LoadingSpinner({ className }: { className?: string }) {
return <LoaderCircle className={cn("size-16 animate-spin", className)} />;
}

View File

@@ -13,10 +13,18 @@ import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
// This neat little feature makes the toaster buggy due to the following issue:
// https://github.com/radix-ui/primitives/issues/2233
// TODO: Re-enable when the above issue is fixed:
// const swipeThreshold = toasts.some((toast) => toast.dismissable === false)
// ? Infinity
// : undefined;
const swipeThreshold = undefined;
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<ToastProvider swipeThreshold={swipeThreshold}>
{toasts.map(
({ id, title, description, action, dismissable, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
@@ -25,10 +33,10 @@ export function Toaster() {
)}
</div>
{action}
<ToastClose />
{dismissable !== false && <ToastClose />}
</Toast>
);
})}
),
)}
<ToastViewport />
</ToastProvider>
);

View File

@@ -13,6 +13,7 @@ type ToasterToast = ToastProps & {
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
dismissable?: boolean;
};
const actionTypes = {

View File

@@ -68,6 +68,7 @@ export default class BackendAPI {
private webSocket: WebSocket | null = null;
private wsConnecting: Promise<void> | null = null;
private wsOnConnectHandlers: Set<() => void> = new Set();
private wsOnDisconnectHandlers: Set<() => void> = new Set();
private wsMessageHandlers: Record<string, Set<(data: any) => void>> = {};
readonly HEARTBEAT_INTERVAL = 100_000; // 100 seconds
@@ -939,43 +940,69 @@ export default class BackendAPI {
return () => this.wsOnConnectHandlers.delete(handler);
}
/**
* All handlers are invoked when the WebSocket disconnects.
*
* @returns a detacher for the passed handler.
*/
onWebSocketDisconnect(handler: () => void): () => void {
this.wsOnDisconnectHandlers.add(handler);
// Return detacher
return () => this.wsOnDisconnectHandlers.delete(handler);
}
async connectWebSocket(): Promise<void> {
this.wsConnecting ??= new Promise(async (resolve, reject) => {
return (this.wsConnecting ??= new Promise(async (resolve, reject) => {
try {
const token =
(await this.supabaseClient?.auth.getSession())?.data.session
?.access_token || "";
const wsUrlWithToken = `${this.wsUrl}?token=${token}`;
this.webSocket = new WebSocket(wsUrlWithToken);
this.webSocket.state = "connecting";
this.webSocket.onopen = () => {
this.webSocket!.state = "connected";
console.info("[BackendAPI] WebSocket connected to", this.wsUrl);
this._startWSHeartbeat(); // Start heartbeat when connection opens
this.wsOnConnectHandlers.forEach((handler) => handler());
resolve();
};
this.webSocket.onclose = (event) => {
console.warn("WebSocket connection closed", event);
if (this.webSocket?.state == "connecting") {
console.error(
`[BackendAPI] WebSocket failed to connect: ${event.reason}`,
event,
);
} else if (this.webSocket?.state == "connected") {
console.warn(
`[BackendAPI] WebSocket connection closed: ${event.reason}`,
event,
);
}
this.webSocket!.state = "closed";
this._stopWSHeartbeat(); // Stop heartbeat when connection closes
this.wsConnecting = null;
this.wsOnDisconnectHandlers.forEach((handler) => handler());
// Attempt to reconnect after a delay
setTimeout(() => this.connectWebSocket(), 1000);
setTimeout(() => this.connectWebSocket().then(resolve), 1000);
};
this.webSocket.onerror = (error) => {
console.error("WebSocket error:", error);
this._stopWSHeartbeat(); // Stop heartbeat on error
this.wsConnecting = null;
reject(error);
if (this.webSocket?.state == "connected") {
console.error("[BackendAPI] WebSocket error:", error);
}
};
this.webSocket.onmessage = (event) => this._handleWSMessage(event);
} catch (error) {
console.error("Error connecting to WebSocket:", error);
console.error("[BackendAPI] Error connecting to WebSocket:", error);
reject(error);
}
});
return this.wsConnecting;
}));
}
disconnectWebSocket() {
@@ -1044,6 +1071,12 @@ export default class BackendAPI {
}
}
declare global {
interface WebSocket {
state: "connecting" | "connected" | "closed";
}
}
/* *** UTILITY TYPES *** */
type GraphCreateRequestBody = {