mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-9867): guided Create-a-Browser-Profile flow (#6043)
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
This commit is contained in:
parent
64b7aad8d4
commit
5083112f22
8 changed files with 652 additions and 47 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { BookmarkIcon, ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -22,6 +22,7 @@ import { useBrowserProfilesQuery } from "@/routes/workflows/hooks/useBrowserProf
|
|||
import { cn } from "@/util/utils";
|
||||
|
||||
import { BrowserProfileItem } from "./BrowserProfileItem";
|
||||
import { CreateBrowserProfileButton } from "./CreateBrowserProfileButton";
|
||||
|
||||
type Props = {
|
||||
searchKey?: string;
|
||||
|
|
@ -103,15 +104,23 @@ function BrowserProfilesList({ searchKey }: Props = {}) {
|
|||
|
||||
if (pageItems.length === 0 && page === 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-700 bg-slate-elevation1 p-6 text-sm text-slate-300">
|
||||
<div className="rounded-md border border-slate-700 bg-slate-elevation1 p-10 text-sm text-slate-300">
|
||||
{hasSearch ? (
|
||||
<>No browser profiles match “{searchKey}”.</>
|
||||
) : (
|
||||
<>
|
||||
No browser profiles yet. Profiles are created via the API/SDK, or
|
||||
when a credential test or workflow run saves its persistent browser
|
||||
session.
|
||||
</>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<BookmarkIcon className="size-10 text-slate-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-medium text-slate-100">
|
||||
No browser profiles yet
|
||||
</p>
|
||||
<p className="mx-auto max-w-md text-sm text-slate-400">
|
||||
Start a session, set up the logged-in state you want to capture,
|
||||
then click “Save Profile” on the session page.
|
||||
</p>
|
||||
</div>
|
||||
<CreateBrowserProfileButton size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useDebounce } from "use-debounce";
|
|||
import { TableSearchInput } from "@/components/TableSearchInput";
|
||||
|
||||
import { BrowserProfilesList } from "./BrowserProfilesList";
|
||||
import { CreateBrowserProfileButton } from "./CreateBrowserProfileButton";
|
||||
import { useBackgroundBrowserProfileCreate } from "./hooks/useBackgroundBrowserProfileCreate";
|
||||
|
||||
const subHeaderText =
|
||||
"Manage saved browser profiles used by your workflow runs.";
|
||||
|
|
@ -14,11 +16,16 @@ function BrowserProfilesPage() {
|
|||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 250);
|
||||
|
||||
// Mounted for its side effects: rehydrates an in-progress create from
|
||||
// sessionStorage so the toast still fires if the user navigates here from
|
||||
// the session page mid-create or reloads the tab.
|
||||
useBackgroundBrowserProfileCreate();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl">Browser Profiles</h1>
|
||||
<div className="w-96 text-sm text-slate-300">{subHeaderText}</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
|
|
@ -30,6 +37,7 @@ function BrowserProfilesPage() {
|
|||
placeholder="Search browser profiles..."
|
||||
className="w-48 lg:w-72"
|
||||
/>
|
||||
<CreateBrowserProfileButton />
|
||||
</div>
|
||||
<BrowserProfilesList searchKey={debouncedSearch} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { PlusIcon, ReloadIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCreateBrowserSessionMutation";
|
||||
import { useBrowserProfileCreateStore } from "@/store/useBrowserProfileCreateStore";
|
||||
|
||||
type Props = {
|
||||
size?: "default" | "lg";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function CreateBrowserProfileButton({
|
||||
size = "default",
|
||||
label = "Create a Browser Profile",
|
||||
}: Props) {
|
||||
const createBrowserSessionMutation = useCreateBrowserSessionMutation();
|
||||
const isBackgroundCreateInProgress = useBrowserProfileCreateStore(
|
||||
(state) => state.active !== null,
|
||||
);
|
||||
|
||||
const disabled =
|
||||
createBrowserSessionMutation.isPending || isBackgroundCreateInProgress;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
title={
|
||||
isBackgroundCreateInProgress
|
||||
? "A browser profile is already being created"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
createBrowserSessionMutation.mutate({
|
||||
proxyLocation: null,
|
||||
timeout: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{createBrowserSessionMutation.isPending ? (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { CreateBrowserProfileButton };
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import type { StartBackgroundCreateInput } from "./hooks/useBackgroundBrowserProfileCreate";
|
||||
|
||||
type Props = {
|
||||
browserSessionId: string;
|
||||
isSessionRunning: boolean;
|
||||
onStartBackgroundCreate: (input: StartBackgroundCreateInput) => void;
|
||||
defaultName?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function SaveSessionAsBrowserProfileDialog({
|
||||
browserSessionId,
|
||||
isSessionRunning,
|
||||
onStartBackgroundCreate,
|
||||
defaultName = "",
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultName);
|
||||
setDescription("");
|
||||
}
|
||||
}, [open, defaultName]);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const canSubmit = trimmedName.length > 0;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
onStartBackgroundCreate({
|
||||
browserSessionId,
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
isSessionRunning,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Browser Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isSessionRunning
|
||||
? "Capture this session's state as a reusable profile. Saving closes the session and runs in the background."
|
||||
: "Capture this session's state as a reusable profile."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 pb-4">
|
||||
<div className="rounded-md border border-slate-700 bg-slate-elevation2 px-3 py-2">
|
||||
<div className="text-xs text-slate-400">Source session</div>
|
||||
<div className="truncate font-mono text-xs text-slate-200">
|
||||
{browserSessionId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="save-browser-profile-name">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="save-browser-profile-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. logged-in-acme-account"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="save-browser-profile-description">
|
||||
Description <span className="text-slate-400">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="save-browser-profile-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="What state does this profile capture?"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
Save Profile
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { SaveSessionAsBrowserProfileDialog };
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { BrowserProfileApiResponse } from "@/api/types";
|
||||
import { ToastAction } from "@/components/ui/toast";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import {
|
||||
type ActiveBrowserProfileCreate,
|
||||
type ActiveCreatePhase,
|
||||
useBrowserProfileCreateStore,
|
||||
} from "@/store/useBrowserProfileCreateStore";
|
||||
|
||||
const MAX_TOTAL_DURATION_MS = 5 * 60 * 1000;
|
||||
const SESSION_POLL_INTERVAL_MS = 5000;
|
||||
const CREATE_RETRY_INTERVAL_MS = 10000;
|
||||
const MAX_CONSECUTIVE_ERRORS = 10;
|
||||
|
||||
const ARCHIVE_NOT_READY_HINT = "persisted profile archive";
|
||||
|
||||
type ActiveRefState = ActiveBrowserProfileCreate & {
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
type StartBackgroundCreateInput = {
|
||||
browserSessionId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isSessionRunning: boolean;
|
||||
};
|
||||
|
||||
type CreateBrowserProfilePayload = {
|
||||
name: string;
|
||||
browser_session_id: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function getErrorDetail(error: AxiosError): string | undefined {
|
||||
const data = error.response?.data as { detail?: string } | undefined;
|
||||
return data?.detail;
|
||||
}
|
||||
|
||||
function useBackgroundBrowserProfileCreate() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
const activeRef = useRef<ActiveRefState | null>(null);
|
||||
const { setActive, clearActive } = useBrowserProfileCreateStore();
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (activeRef.current?.timeoutId) {
|
||||
clearTimeout(activeRef.current.timeoutId);
|
||||
}
|
||||
activeRef.current = null;
|
||||
clearActive();
|
||||
}, [clearActive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeRef.current?.timeoutId) {
|
||||
clearTimeout(activeRef.current.timeoutId);
|
||||
activeRef.current.timeoutId = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistPhase = useCallback(
|
||||
(phase: ActiveCreatePhase) => {
|
||||
if (!activeRef.current) return;
|
||||
activeRef.current.phase = phase;
|
||||
setActive({
|
||||
browserSessionId: activeRef.current.browserSessionId,
|
||||
name: activeRef.current.name,
|
||||
description: activeRef.current.description,
|
||||
startTime: activeRef.current.startTime,
|
||||
phase,
|
||||
});
|
||||
},
|
||||
[setActive],
|
||||
);
|
||||
|
||||
const tryCreate = useCallback(async () => {
|
||||
const active = activeRef.current;
|
||||
if (!active) return;
|
||||
|
||||
if (Date.now() - active.startTime > MAX_TOTAL_DURATION_MS) {
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Browser profile creation timed out",
|
||||
description:
|
||||
"The session archive was not ready within 5 minutes. You can retry from the profiles page.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
const payload: CreateBrowserProfilePayload = {
|
||||
name: active.name,
|
||||
browser_session_id: active.browserSessionId,
|
||||
};
|
||||
if (active.description) {
|
||||
payload.description = active.description;
|
||||
}
|
||||
const response = await client.post<BrowserProfileApiResponse>(
|
||||
"/browser_profiles",
|
||||
payload,
|
||||
);
|
||||
const profile = response.data;
|
||||
|
||||
cleanup();
|
||||
queryClient.invalidateQueries({ queryKey: ["browserProfiles"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["browserProfiles-infinite"],
|
||||
});
|
||||
toast({
|
||||
title: "Browser profile created",
|
||||
variant: "success",
|
||||
description: `"${profile.name}" was saved from this browser session.`,
|
||||
action: (
|
||||
<ToastAction altText="View profile" asChild>
|
||||
<Link to={`/browser-profiles/${profile.browser_profile_id}`}>
|
||||
View profile
|
||||
</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const detail = getErrorDetail(axiosError);
|
||||
|
||||
if (
|
||||
axiosError.response?.status === 400 &&
|
||||
typeof detail === "string" &&
|
||||
detail.includes(ARCHIVE_NOT_READY_HINT)
|
||||
) {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.errorCount = 0;
|
||||
activeRef.current.timeoutId = setTimeout(
|
||||
tryCreate,
|
||||
CREATE_RETRY_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!axiosError.response) {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.errorCount++;
|
||||
if (activeRef.current.errorCount >= MAX_CONSECUTIVE_ERRORS) {
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Connection lost",
|
||||
description:
|
||||
"Unable to finish creating browser profile. Check your network and retry.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeRef.current.timeoutId = setTimeout(
|
||||
tryCreate,
|
||||
CREATE_RETRY_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Failed to create browser profile",
|
||||
description: detail ?? axiosError.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [credentialGetter, queryClient, cleanup]);
|
||||
|
||||
const pollUntilClosed = useCallback(async () => {
|
||||
const active = activeRef.current;
|
||||
if (!active) return;
|
||||
|
||||
if (Date.now() - active.startTime > MAX_TOTAL_DURATION_MS) {
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Browser profile creation timed out",
|
||||
description: "The session did not finish closing within 5 minutes.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
const response = await client.get<{ status: string }>(
|
||||
`/browser_sessions/${active.browserSessionId}`,
|
||||
);
|
||||
|
||||
if (response.data.status === "completed") {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.errorCount = 0;
|
||||
persistPhase("creating");
|
||||
activeRef.current.timeoutId = setTimeout(tryCreate, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.status === "failed") {
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Browser session failed",
|
||||
description:
|
||||
"Could not create profile — the session ended in a failed state.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRef.current) {
|
||||
activeRef.current.errorCount = 0;
|
||||
activeRef.current.timeoutId = setTimeout(
|
||||
pollUntilClosed,
|
||||
SESSION_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.errorCount++;
|
||||
if (activeRef.current.errorCount >= MAX_CONSECUTIVE_ERRORS) {
|
||||
cleanup();
|
||||
toast({
|
||||
title: "Connection lost",
|
||||
description: "Unable to track browser session status.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeRef.current.timeoutId = setTimeout(
|
||||
pollUntilClosed,
|
||||
SESSION_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [credentialGetter, cleanup, persistPhase, tryCreate]);
|
||||
|
||||
const rehydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (rehydratedRef.current) return;
|
||||
rehydratedRef.current = true;
|
||||
|
||||
const stored = useBrowserProfileCreateStore.getState().active;
|
||||
if (!stored || activeRef.current) return;
|
||||
|
||||
if (Date.now() - stored.startTime > MAX_TOTAL_DURATION_MS) {
|
||||
clearActive();
|
||||
return;
|
||||
}
|
||||
|
||||
activeRef.current = {
|
||||
...stored,
|
||||
timeoutId: null,
|
||||
errorCount: 0,
|
||||
};
|
||||
activeRef.current.timeoutId =
|
||||
stored.phase === "creating"
|
||||
? setTimeout(tryCreate, CREATE_RETRY_INTERVAL_MS)
|
||||
: setTimeout(pollUntilClosed, SESSION_POLL_INTERVAL_MS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs once on mount
|
||||
}, []);
|
||||
|
||||
const startBackgroundCreate = useCallback(
|
||||
async ({
|
||||
browserSessionId,
|
||||
name,
|
||||
description,
|
||||
isSessionRunning,
|
||||
}: StartBackgroundCreateInput) => {
|
||||
cleanup();
|
||||
|
||||
const startTime = Date.now();
|
||||
const initialPhase: ActiveCreatePhase = isSessionRunning
|
||||
? "closing"
|
||||
: "creating";
|
||||
|
||||
activeRef.current = {
|
||||
browserSessionId,
|
||||
name,
|
||||
description,
|
||||
startTime,
|
||||
phase: initialPhase,
|
||||
timeoutId: null,
|
||||
errorCount: 0,
|
||||
};
|
||||
setActive({
|
||||
browserSessionId,
|
||||
name,
|
||||
description,
|
||||
startTime,
|
||||
phase: initialPhase,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Creating browser profile",
|
||||
description: isSessionRunning
|
||||
? "Closing the session and capturing its state. When done, you'll see it in Browser Profiles."
|
||||
: "Capturing the session's state. When done, you'll see it in Browser Profiles.",
|
||||
});
|
||||
|
||||
if (!isSessionRunning) {
|
||||
activeRef.current.timeoutId = setTimeout(tryCreate, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
await client.post(`/browser_sessions/${browserSessionId}/close`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["browserSession", browserSessionId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["browserSessions"] });
|
||||
|
||||
if (activeRef.current) {
|
||||
persistPhase("waiting");
|
||||
activeRef.current.timeoutId = setTimeout(
|
||||
pollUntilClosed,
|
||||
SESSION_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
const axiosError = error as AxiosError;
|
||||
const detail = getErrorDetail(axiosError);
|
||||
toast({
|
||||
title: "Failed to close browser session",
|
||||
description:
|
||||
detail ?? "Could not close the session to capture its state.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
credentialGetter,
|
||||
queryClient,
|
||||
cleanup,
|
||||
persistPhase,
|
||||
pollUntilClosed,
|
||||
tryCreate,
|
||||
setActive,
|
||||
],
|
||||
);
|
||||
|
||||
return { startBackgroundCreate };
|
||||
}
|
||||
|
||||
export type { StartBackgroundCreateInput };
|
||||
export { useBackgroundBrowserProfileCreate };
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ReloadIcon, StopIcon } from "@radix-ui/react-icons";
|
||||
import { BookmarkIcon, ReloadIcon, StopIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -21,6 +21,8 @@ import { SwitchBarNavigation } from "@/components/SwitchBarNavigation";
|
|||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useCloseBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCloseBrowserSessionMutation";
|
||||
import { SaveSessionAsBrowserProfileDialog } from "@/routes/browserProfiles/SaveSessionAsBrowserProfileDialog";
|
||||
import { useBackgroundBrowserProfileCreate } from "@/routes/browserProfiles/hooks/useBackgroundBrowserProfileCreate";
|
||||
import { CopyText } from "@/routes/workflows/editor/Workspace";
|
||||
import { type BrowserSession as BrowserSessionType } from "@/routes/workflows/types/browserSessionTypes";
|
||||
import { browserStreamingMode } from "@/util/env";
|
||||
|
|
@ -45,6 +47,7 @@ function BrowserSession() {
|
|||
? "runs"
|
||||
: "stream";
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isSaveProfileDialogOpen, setIsSaveProfileDialogOpen] = useState(false);
|
||||
const [vncFailed, setVncFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -52,6 +55,7 @@ function BrowserSession() {
|
|||
}, [browserSessionId]);
|
||||
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const { startBackgroundCreate } = useBackgroundBrowserProfileCreate();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["browserSession", browserSessionId],
|
||||
|
|
@ -156,40 +160,49 @@ function BrowserSession() {
|
|||
/>
|
||||
|
||||
{browserSessionId && browserSession?.status === "running" && (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="ml-auto" variant="secondary">
|
||||
<StopIcon className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to stop (shut down) this browser
|
||||
session?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Back</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
closeBrowserSessionMutation.mutate();
|
||||
}}
|
||||
disabled={closeBrowserSessionMutation.isPending}
|
||||
>
|
||||
{closeBrowserSessionMutation.isPending && (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Stop Browser Session
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setIsSaveProfileDialogOpen(true)}
|
||||
>
|
||||
<BookmarkIcon className="mr-2 h-4 w-4" />
|
||||
Save Profile
|
||||
</Button>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<StopIcon className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to stop (shut down) this browser
|
||||
session?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Back</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
closeBrowserSessionMutation.mutate();
|
||||
}}
|
||||
disabled={closeBrowserSessionMutation.isPending}
|
||||
>
|
||||
{closeBrowserSessionMutation.isPending && (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Stop Browser Session
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -253,6 +266,15 @@ function BrowserSession() {
|
|||
</div>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
{browserSessionId && (
|
||||
<SaveSessionAsBrowserProfileDialog
|
||||
browserSessionId={browserSessionId}
|
||||
isSessionRunning={browserSession?.status === "running"}
|
||||
onStartBackgroundCreate={startBackgroundCreate}
|
||||
open={isSaveProfileDialogOpen}
|
||||
onOpenChange={setIsSaveProfileDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ function SideNav() {
|
|||
to: "/browser-sessions",
|
||||
icon: <GlobeIcon className="size-6" />,
|
||||
},
|
||||
{
|
||||
label: "Browser Profiles",
|
||||
to: "/browser-profiles",
|
||||
icon: <BrowserIcon className="size-6" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<NavLinkGroup
|
||||
|
|
@ -64,11 +69,6 @@ function SideNav() {
|
|||
to: "/credentials",
|
||||
icon: <KeyIcon className="size-6" />,
|
||||
},
|
||||
{
|
||||
label: "Browser Profiles",
|
||||
to: "/browser-profiles",
|
||||
icon: <BrowserIcon className="size-6" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</nav>
|
||||
|
|
|
|||
34
skyvern-frontend/src/store/useBrowserProfileCreateStore.ts
Normal file
34
skyvern-frontend/src/store/useBrowserProfileCreateStore.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
|
||||
type ActiveCreatePhase = "closing" | "waiting" | "creating";
|
||||
|
||||
type ActiveBrowserProfileCreate = {
|
||||
browserSessionId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
startTime: number;
|
||||
phase: ActiveCreatePhase;
|
||||
};
|
||||
|
||||
type BrowserProfileCreateStore = {
|
||||
active: ActiveBrowserProfileCreate | null;
|
||||
setActive: (active: ActiveBrowserProfileCreate) => void;
|
||||
clearActive: () => void;
|
||||
};
|
||||
|
||||
export type { ActiveBrowserProfileCreate, ActiveCreatePhase };
|
||||
|
||||
export const useBrowserProfileCreateStore = create<BrowserProfileCreateStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
active: null,
|
||||
setActive: (active) => set({ active }),
|
||||
clearActive: () => set({ active: null }),
|
||||
}),
|
||||
{
|
||||
name: "browser-profile-create",
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue