Cycle Browser in Debugger (#3082)

This commit is contained in:
Jonathan Dobson
2025-08-01 09:16:54 -04:00
committed by GitHub
parent 67717aa987
commit e0e3fd1622
4 changed files with 239 additions and 10 deletions

View File

@@ -0,0 +1,42 @@
interface AnimatedWaveProps {
text: string;
className?: string;
}
export function AnimatedWave({ text, className = "" }: AnimatedWaveProps) {
const characters = text.split("");
return (
<>
<style>{`
@keyframes wave {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-4px);
}
}
.animate-wave {
animation-name: wave;
}
`}</style>
<span className={`inline-flex ${className}`}>
{characters.map((char, index) => (
<span
key={index}
className="animate-wave inline-block"
style={{
animationDelay: `${index * 0.1}s`,
animationDuration: "1.3s",
animationIterationCount: "infinite",
animationTimingFunction: "ease-in-out",
}}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</span>
</>
);
}

View File

@@ -18,7 +18,14 @@ import {
import { flushSync } from "react-dom";
import Draggable from "react-draggable";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/util/utils";
import { PowerIcon } from "./icons/PowerIcon";
type OS = "Windows" | "macOS" | "Linux" | "Unknown";
@@ -70,11 +77,41 @@ function WindowsButton(props: {
);
}
function PowerButton(props: { onClick: () => void }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
className="h-[1.2rem] w-[1.25rem] opacity-50 hover:opacity-100"
onClick={() => props.onClick()}
>
<PowerIcon />
</button>
</TooltipTrigger>
<TooltipContent>Cycle (New Browser)</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function ReloadButton(props: { isReloading: boolean; onClick: () => void }) {
return (
<button onClick={() => props.onClick()}>
<ReloadIcon className={props.isReloading ? "animate-spin" : undefined} />
</button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
className="opacity-50 hover:opacity-100"
onClick={() => props.onClick()}
>
<ReloadIcon
className={props.isReloading ? "animate-spin" : undefined}
/>
</button>
</TooltipTrigger>
<TooltipContent>Reconnect</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -114,10 +151,12 @@ function FloatingWindow({
showCloseButton,
showMaximizeButton,
showMinimizeButton,
showPowerButton,
showReloadButton = false,
title,
zIndex,
// --
onCycle,
onInteract,
}: {
bounded?: boolean;
@@ -128,10 +167,12 @@ function FloatingWindow({
showCloseButton?: boolean;
showMaximizeButton?: boolean;
showMinimizeButton?: boolean;
showPowerButton?: boolean;
showReloadButton?: boolean;
title: string;
zIndex?: string;
// --
onCycle?: () => void;
onInteract?: () => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
@@ -420,6 +461,10 @@ function FloatingWindow({
}, 1000);
};
const cycle = () => {
onCycle?.();
};
/**
* If maximized, need to retain max size during parent resizing.
*/
@@ -582,6 +627,7 @@ function FloatingWindow({
onClick={toggleMaximized}
/>
)}
{showPowerButton && <PowerButton onClick={() => cycle()} />}
</div>
<div className="ml-auto">{title}</div>
{showReloadButton && (
@@ -601,6 +647,7 @@ function FloatingWindow({
)}
<div>{title}</div>
<div className="buttons-container ml-auto flex h-full items-center gap-2">
{showPowerButton && <PowerButton onClick={() => cycle()} />}
{showMinimizeButton && (
<WindowsButton
onClick={toggleMinimized}

View File

@@ -0,0 +1,27 @@
type Props = {
className?: string;
};
function PowerIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
className={className}
>
<path
d="M13 3C13 2.44772 12.5523 2 12 2C11.4477 2 11 2.44772 11 3V13C11 13.5523 11.4477 14 12 14C12.5523 14 13 13.5523 13 13V3Z"
fill="currentColor"
/>
<path
d="M6.2798 6.70707C6.67031 6.31653 6.67028 5.68337 6.27973 5.29286C5.88919 4.90235 5.25603 4.90238 4.86552 5.29293C3.09635 7.06226 2 9.49298 2 12.1764C2 17.6204 6.49591 22 12 22C17.5041 22 22 17.6204 22 12.1764C22 9.49298 20.9036 7.06226 19.1345 5.29293C18.744 4.90238 18.1108 4.90235 17.7203 5.29286C17.3297 5.68337 17.3297 6.31653 17.7202 6.70707C19.1338 8.12083 20 10.0503 20 12.1764C20 16.4787 16.437 20 12 20C7.56296 20 4 16.4787 4 12.1764C4 10.0503 4.86618 8.12083 6.2798 6.70707Z"
fill="currentColor"
/>
</svg>
);
}
export { PowerIcon };

View File

@@ -1,12 +1,30 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ReactFlowProvider } from "@xyflow/react";
import { getClient } from "@/api/AxiosClient";
import { DebugSessionApiResponse } from "@/api/types";
import { AnimatedWave } from "@/components/AnimatedWave";
import { BrowserStream } from "@/components/BrowserStream";
import { FloatingWindow } from "@/components/FloatingWindow";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useMountEffect } from "@/hooks/useMountEffect";
import { useUser } from "@/hooks/useUser";
import { statusIsFinalized } from "@/routes/tasks/types.ts";
import { useSidebarStore } from "@/store/SidebarStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
@@ -19,9 +37,16 @@ import { getElements } from "./workflowEditorUtils";
import { getInitialParameters } from "./utils";
function WorkflowDebugger() {
const { workflowPermanentId } = useParams();
const { blockLabel, workflowPermanentId } = useParams();
const [openDialogue, setOpenDialogue] = useState(false);
const [activeDebugSession, setActiveDebugSession] =
useState<DebugSessionApiResponse | null>(null);
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
const user = useUser().get();
const email = user?.email;
const isSkyvernUser = email?.toLowerCase().endsWith("@skyvern.com") ?? false;
const { data: workflowRun } = useWorkflowRunQuery();
const { data: workflow } = useWorkflowQuery({
@@ -41,6 +66,10 @@ function WorkflowDebugger() {
(state) => state.setHasChanges,
);
const handleOnCycle = () => {
setOpenDialogue(true);
};
useMountEffect(() => {
setCollapsed(true);
setHasChanges(false);
@@ -53,6 +82,37 @@ function WorkflowDebugger() {
}
});
const cycleBrowser = useMutation({
mutationFn: async (id: string) => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client.post<DebugSessionApiResponse>(`/debug-session/${id}/new`);
},
onSuccess: (response) => {
const newDebugSession = response.data;
setActiveDebugSession(newDebugSession);
queryClient.invalidateQueries({
queryKey: ["debugSession", workflowPermanentId],
});
toast({
title: "Browser cycled",
variant: "success",
description: "Your browser has been cycled.",
});
setOpenDialogue(false);
},
onError: (error: AxiosError) => {
toast({
variant: "destructive",
title: "Failed to cycle browser",
description: error.message,
});
setOpenDialogue(false);
},
});
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
@@ -71,6 +131,10 @@ function WorkflowDebugger() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (debugSession) {
setActiveDebugSession(debugSession);
}
}
return () => {
@@ -107,6 +171,52 @@ function WorkflowDebugger() {
return (
<div className="relative flex h-screen w-full">
<Dialog
open={openDialogue}
onOpenChange={(open) => {
if (!open && cycleBrowser.isPending) {
return;
}
setOpenDialogue(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Cycle (Get a new browser)</DialogTitle>
<DialogDescription>
<div className="pb-2 pt-4 text-sm text-slate-400">
{cycleBrowser.isPending ? (
<>
Cooking you up a fresh browser...
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
</>
) : (
"Abandon this browser for a new one. Are you sure?"
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
{!cycleBrowser.isPending && (
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
)}
<Button
variant="default"
onClick={() => {
cycleBrowser.mutate(workflowPermanentId!);
}}
disabled={cycleBrowser.isPending}
>
Yes, Continue{" "}
{cycleBrowser.isPending && (
<ReloadIcon className="ml-2 size-4 animate-spin" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ReactFlowProvider>
<FlowRenderer
initialEdges={elements.edges}
@@ -117,7 +227,7 @@ function WorkflowDebugger() {
/>
</ReactFlowProvider>
{debugSession && (
{activeDebugSession && (
<FloatingWindow
title={browserTitle}
bounded={false}
@@ -125,12 +235,15 @@ function WorkflowDebugger() {
initialHeight={360}
showMaximizeButton={true}
showMinimizeButton={true}
showPowerButton={blockLabel === undefined && isSkyvernUser}
showReloadButton={true}
// --
onCycle={handleOnCycle}
>
{debugSession && debugSession.browser_session_id ? (
{activeDebugSession && activeDebugSession.browser_session_id ? (
<BrowserStream
interactive={interactor === "human"}
browserSessionId={debugSession.browser_session_id}
browserSessionId={activeDebugSession.browser_session_id}
/>
) : (
<Skeleton className="h-full w-full" />