mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
committed by
GitHub
parent
ac532ca4b9
commit
0a79e1c5fd
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
26
autogpt_platform/frontend/src/components/ui/loading.tsx
Normal file
26
autogpt_platform/frontend/src/components/ui/loading.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ type ToasterToast = ToastProps & {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
dismissable?: boolean;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user