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

This commit is contained in:
Celal Zamanoğlu 2026-05-19 00:05:01 +03:00 committed by GitHub
parent 64b7aad8d4
commit 5083112f22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 652 additions and 47 deletions

View file

@ -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 &ldquo;{searchKey}&rdquo;.</>
) : (
<>
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 &ldquo;Save Profile&rdquo; on the session page.
</p>
</div>
<CreateBrowserProfileButton size="lg" />
</div>
)}
</div>
);

View file

@ -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>

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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>
);
}

View file

@ -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>

View 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),
},
),
);