Cycle Browser in Debugger (#3082)
This commit is contained in:
42
skyvern-frontend/src/components/AnimatedWave.tsx
Normal file
42
skyvern-frontend/src/components/AnimatedWave.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
27
skyvern-frontend/src/components/icons/PowerIcon.tsx
Normal file
27
skyvern-frontend/src/components/icons/PowerIcon.tsx
Normal 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 };
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user