+>(({ className, ...props }, ref) => (
+ | [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/skyvern-frontend/src/components/ui/textarea.tsx b/skyvern-frontend/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..568256b3
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/util/utils";
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/skyvern-frontend/src/components/ui/toast.tsx b/skyvern-frontend/src/components/ui/toast.tsx
new file mode 100644
index 00000000..372b8cf2
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import * as ToastPrimitives from "@radix-ui/react-toast";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/util/utils";
+
+const ToastProvider = ToastPrimitives.Provider;
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ );
+});
+Toast.displayName = ToastPrimitives.Root.displayName;
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastAction.displayName = ToastPrimitives.Action.displayName;
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+ToastClose.displayName = ToastPrimitives.Close.displayName;
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastTitle.displayName = ToastPrimitives.Title.displayName;
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastDescription.displayName = ToastPrimitives.Description.displayName;
+
+type ToastProps = React.ComponentPropsWithoutRef;
+
+type ToastActionElement = React.ReactElement;
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+};
diff --git a/skyvern-frontend/src/components/ui/toaster.tsx b/skyvern-frontend/src/components/ui/toaster.tsx
new file mode 100644
index 00000000..5ff57090
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/toaster.tsx
@@ -0,0 +1,33 @@
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast";
+import { useToast } from "@/components/ui/use-toast";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/skyvern-frontend/src/components/ui/tooltip.tsx b/skyvern-frontend/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..dd030d7f
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/tooltip.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/util/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/skyvern-frontend/src/components/ui/use-form-field.ts b/skyvern-frontend/src/components/ui/use-form-field.ts
new file mode 100644
index 00000000..76f31e33
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/use-form-field.ts
@@ -0,0 +1,46 @@
+import React from "react";
+import { FieldPath, FieldValues, useFormContext } from "react-hook-form";
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName;
+};
+
+export const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue,
+);
+
+type FormItemContextValue = {
+ id: string;
+};
+
+export const FormItemContext = React.createContext(
+ {} as FormItemContextValue,
+);
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ");
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+export { useFormField };
diff --git a/skyvern-frontend/src/components/ui/use-toast.ts b/skyvern-frontend/src/components/ui/use-toast.ts
new file mode 100644
index 00000000..5a03b78a
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/use-toast.ts
@@ -0,0 +1,189 @@
+// Inspired by react-hot-toast library
+import * as React from "react";
+
+import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
+
+const TOAST_LIMIT = 1;
+const TOAST_REMOVE_DELAY = 1000000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+};
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const;
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
+ return count.toString();
+}
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map>();
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t,
+ ),
+ };
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t,
+ ),
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
+};
+
+const listeners: Array<(state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+type Toast = Omit;
+
+function toast({ ...props }: Toast) {
+ const id = genId();
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
+ },
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ };
+}
+
+export { useToast, toast };
diff --git a/skyvern-frontend/src/index.css b/skyvern-frontend/src/index.css
index 8abdb15c..b16ead87 100644
--- a/skyvern-frontend/src/index.css
+++ b/skyvern-frontend/src/index.css
@@ -74,3 +74,8 @@
@apply bg-background text-foreground;
}
}
+
+body,
+#root {
+ min-height: 100vh;
+}
diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx
new file mode 100644
index 00000000..9a2419ee
--- /dev/null
+++ b/skyvern-frontend/src/router.tsx
@@ -0,0 +1,51 @@
+import { Navigate, createBrowserRouter } from "react-router-dom";
+import { RootLayout } from "./routes/root/RootLayout";
+import { TasksPageLayout } from "./routes/tasks/TasksPageLayout";
+import { CreateNewTask } from "./routes/tasks/create/CreateNewTask";
+import { TaskList } from "./routes/tasks/list/TaskList";
+import { Settings } from "./routes/settings/Settings";
+import { SettingsPageLayout } from "./routes/settings/SettingsPageLayout";
+import { TaskDetails } from "./routes/tasks/detail/TaskDetails";
+
+const router = createBrowserRouter([
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: "tasks",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ":taskId",
+ element: ,
+ },
+ ],
+ },
+ {
+ path: "create",
+ element: ,
+ },
+ {
+ path: "settings",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+]);
+
+export { router };
diff --git a/skyvern-frontend/src/routes/root/RootLayout.tsx b/skyvern-frontend/src/routes/root/RootLayout.tsx
new file mode 100644
index 00000000..9f053a12
--- /dev/null
+++ b/skyvern-frontend/src/routes/root/RootLayout.tsx
@@ -0,0 +1,49 @@
+import { Link, Outlet } from "react-router-dom";
+import { Toaster } from "@/components/ui/toaster";
+import { SideNav } from "./SideNav";
+import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
+
+function RootLayout() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export { RootLayout };
diff --git a/skyvern-frontend/src/routes/root/SideNav.tsx b/skyvern-frontend/src/routes/root/SideNav.tsx
new file mode 100644
index 00000000..b0107be2
--- /dev/null
+++ b/skyvern-frontend/src/routes/root/SideNav.tsx
@@ -0,0 +1,58 @@
+import { cn } from "@/util/utils";
+import {
+ GearIcon,
+ ListBulletIcon,
+ PlusCircledIcon,
+} from "@radix-ui/react-icons";
+import { NavLink } from "react-router-dom";
+
+function SideNav() {
+ return (
+
+ );
+}
+
+export { SideNav };
diff --git a/skyvern-frontend/src/routes/settings/Settings.tsx b/skyvern-frontend/src/routes/settings/Settings.tsx
new file mode 100644
index 00000000..1248f7c6
--- /dev/null
+++ b/skyvern-frontend/src/routes/settings/Settings.tsx
@@ -0,0 +1,49 @@
+import { Label } from "@/components/ui/label";
+import { useId } from "react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useSettingsStore } from "@/store/SettingsStore";
+
+function Settings() {
+ const { environment, organization, setEnvironment, setOrganization } =
+ useSettingsStore();
+ const environmentInputId = useId();
+ const organizationInputId = useId();
+
+ return (
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { Settings };
diff --git a/skyvern-frontend/src/routes/settings/SettingsPageLayout.tsx b/skyvern-frontend/src/routes/settings/SettingsPageLayout.tsx
new file mode 100644
index 00000000..7945597c
--- /dev/null
+++ b/skyvern-frontend/src/routes/settings/SettingsPageLayout.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from "react-router-dom";
+
+function SettingsPageLayout() {
+ return (
+
+
+
+
+
+ );
+}
+
+export { SettingsPageLayout };
diff --git a/skyvern-frontend/src/routes/tasks/TasksPageLayout.tsx b/skyvern-frontend/src/routes/tasks/TasksPageLayout.tsx
new file mode 100644
index 00000000..ddd21d15
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/TasksPageLayout.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from "react-router-dom";
+
+function TasksPageLayout() {
+ return (
+
+
+
+
+
+ );
+}
+
+export { TasksPageLayout };
diff --git a/skyvern-frontend/src/routes/tasks/constants.ts b/skyvern-frontend/src/routes/tasks/constants.ts
new file mode 100644
index 00000000..5ef99a2b
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/constants.ts
@@ -0,0 +1 @@
+export const PAGE_SIZE = 15;
diff --git a/skyvern-frontend/src/routes/tasks/create/CreateNewTask.tsx b/skyvern-frontend/src/routes/tasks/create/CreateNewTask.tsx
new file mode 100644
index 00000000..5651c7be
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/create/CreateNewTask.tsx
@@ -0,0 +1,49 @@
+import { useId, useState } from "react";
+import { CreateNewTaskForm } from "./CreateNewTaskForm";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { SampleCase } from "../types";
+import { getSampleForInitialFormValues } from "../data/sampleTaskData";
+import { Label } from "@/components/ui/label";
+
+function CreateNewTask() {
+ const [selectedCase, setSelectedCase] = useState("geico");
+ const caseInputId = useId();
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export { CreateNewTask };
diff --git a/skyvern-frontend/src/routes/tasks/create/CreateNewTaskForm.tsx b/skyvern-frontend/src/routes/tasks/create/CreateNewTaskForm.tsx
new file mode 100644
index 00000000..e5da3388
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/create/CreateNewTaskForm.tsx
@@ -0,0 +1,297 @@
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ dataExtractionGoalDescription,
+ extractedInformationSchemaDescription,
+ navigationGoalDescription,
+ navigationPayloadDescription,
+ urlDescription,
+ webhookCallbackUrlDescription,
+} from "../data/descriptionHelperContent";
+import { Textarea } from "@/components/ui/textarea";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { client } from "@/api/AxiosClient";
+import { useToast } from "@/components/ui/use-toast";
+import { InfoCircledIcon } from "@radix-ui/react-icons";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { ToastAction } from "@radix-ui/react-toast";
+import { Link } from "react-router-dom";
+
+const createNewTaskFormSchema = z.object({
+ url: z.string().url({
+ message: "Invalid URL",
+ }),
+ webhookCallbackUrl: z.string().optional(), // url maybe, but shouldn't be validated as one
+ navigationGoal: z.string().optional(),
+ dataExtractionGoal: z.string().optional(),
+ navigationPayload: z.string().optional(),
+ extractedInformationSchema: z.string().optional(),
+});
+
+export type CreateNewTaskFormValues = z.infer;
+
+type Props = {
+ initialValues: CreateNewTaskFormValues;
+};
+
+function createTaskRequestObject(formValues: CreateNewTaskFormValues) {
+ return {
+ url: formValues.url,
+ webhook_callback_url: formValues.webhookCallbackUrl ?? "",
+ navigation_goal: formValues.navigationGoal ?? "",
+ data_extraction_goal: formValues.dataExtractionGoal ?? "",
+ proxy_location: "NONE",
+ navigation_payload: formValues.navigationPayload ?? "",
+ extracted_information_schema: formValues.extractedInformationSchema ?? "",
+ };
+}
+
+function CreateNewTaskForm({ initialValues }: Props) {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(createNewTaskFormSchema),
+ defaultValues: initialValues,
+ });
+
+ const mutation = useMutation({
+ mutationFn: (formValues: CreateNewTaskFormValues) => {
+ const taskRequest = createTaskRequestObject(formValues);
+ return client.post<
+ ReturnType,
+ { data: { task_id: string } }
+ >("/tasks", taskRequest);
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message,
+ });
+ },
+ onSuccess: (response) => {
+ toast({
+ title: "Task Created",
+ description: `${response.data.task_id} created successfully.`,
+ action: (
+
+
+
+ ),
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["tasks"],
+ });
+ },
+ });
+
+ function onSubmit(values: CreateNewTaskFormValues) {
+ mutation.mutate(values);
+ }
+
+ return (
+
+
+ );
+}
+
+export { CreateNewTaskForm };
diff --git a/skyvern-frontend/src/routes/tasks/data/descriptionHelperContent.ts b/skyvern-frontend/src/routes/tasks/data/descriptionHelperContent.ts
new file mode 100644
index 00000000..b6e0b72c
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/data/descriptionHelperContent.ts
@@ -0,0 +1,16 @@
+export const urlDescription = "The starting URL for the task.";
+
+export const webhookCallbackUrlDescription =
+ "The URL to call with the results when the task is completed.";
+
+export const navigationGoalDescription =
+ "The user's goal for the task. Nullable if the task is only for data extraction.";
+
+export const dataExtractionGoalDescription =
+ "The user's goal for data extraction. Nullable if the task is only for navigation.";
+
+export const navigationPayloadDescription =
+ "The user's details needed to achieve the task. This is an unstructured field, and information can be passed in in any format you desire. Skyvern will map this information to the questions on the screen in real-time.";
+
+export const extractedInformationSchemaDescription =
+ "(Optional) The requested schema of the extracted information for data extraction goal. This is a JSON object with keys as the field names and values as the data types. The data types can be any of the following: string, number, boolean, date, datetime, time, float, integer, object, array, null. If the schema is not provided, Skyvern will infer the schema from the extracted data.";
diff --git a/skyvern-frontend/src/routes/tasks/data/sampleTaskData.ts b/skyvern-frontend/src/routes/tasks/data/sampleTaskData.ts
new file mode 100644
index 00000000..2f9d37ee
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/data/sampleTaskData.ts
@@ -0,0 +1,282 @@
+import { SampleCase } from "../types";
+
+export const bci_seguros = {
+ url: "https://www.bciseguros.cl/nuestros_seguros/personas/seguro-automotriz/",
+ navigationGoal:
+ "Generate an auto insurance quote. A quote has been generated when there's a table of coverages shown on the website.",
+ dataExtractionGoal:
+ "Extract ALL quote information in JSON format, with one entry per plan visible on the page. The output should include: the selected UF coverage value (3), auto plan name, the online price",
+ navigationPayload: {
+ Rut: "7.250.199-3",
+ Sexo: "Masculino",
+ "Fecha de Nacimiento": "03-02-2000",
+ Telefono: "96908116",
+ Comuna: "Lo Barnachea",
+ "e-mail": "notarealemail@gmail.com",
+ estado: "Usado",
+ patente: "HZZV68",
+ marca: "Subaru",
+ modelo: "XV",
+ ano: "2016",
+ "tipo de combustible": "Bencina",
+ "km approx a recorrer": "28,000",
+ },
+};
+
+export const california_edd = {
+ url: "https://eddservices.edd.ca.gov/acctservices/AccountManagement/AccountServlet?Command=NEW_SIGN_UP",
+ navigationGoal:
+ "Navigate through the employer services online enrollment form. Terminate when the form is completed",
+ navigationPayload: {
+ username: "isthisreal1",
+ password: "Password123!",
+ first_name: "John",
+ last_name: "Doe",
+ pin: "1234",
+ email: "isthisreal1@gmail.com",
+ phone_number: "412-444-1234",
+ },
+};
+
+export const finditparts = {
+ url: "https://www.finditparts.com",
+ navigationGoal:
+ "Search for the specified product id, add it to cart and then navigate to the cart page",
+ dataExtractionGoal:
+ "Extract all product quantity information from the cart page",
+ navigationPayload: {
+ product_id: "W01-377-8537",
+ },
+};
+
+export const geico = {
+ url: "https://www.geico.com",
+ navigationGoal:
+ "Navigate through the website until you generate an auto insurance quote. Do not generate a home insurance quote. If this page contains an auto insurance quote, consider the goal achieved",
+ dataExtractionGoal:
+ "Extract all quote information in JSON format including the premium amount, the timeframe for the quote.",
+ navigationPayload: {
+ licensed_at_age: 19,
+ education_level: "HIGH_SCHOOL",
+ phone_number: "8042221111",
+ full_name: "Chris P. Bacon",
+ past_claim: [],
+ has_claims: false,
+ spouse_occupation: "Florist",
+ auto_current_carrier: "None",
+ home_commercial_uses: null,
+ spouse_full_name: "Amy Stake",
+ auto_commercial_uses: null,
+ requires_sr22: false,
+ previous_address_move_date: null,
+ line_of_work: null,
+ spouse_age: "1987-12-12",
+ auto_insurance_deadline: null,
+ email: "chris.p.bacon@abc.com",
+ net_worth_numeric: 1000000,
+ spouse_gender: "F",
+ marital_status: "married",
+ spouse_licensed_at_age: 20,
+ license_number: "AAAAAAA090AA",
+ spouse_license_number: "AAAAAAA080AA",
+ how_much_can_you_lose: 25000,
+ vehicles: [
+ {
+ annual_mileage: 10000,
+ commute_mileage: 4000,
+ existing_coverages: null,
+ ideal_coverages: {
+ bodily_injury_per_incident_limit: 50000,
+ bodily_injury_per_person_limit: 25000,
+ collision_deductible: 1000,
+ comprehensive_deductible: 1000,
+ personal_injury_protection: null,
+ property_damage_per_incident_limit: null,
+ property_damage_per_person_limit: 25000,
+ rental_reimbursement_per_incident_limit: null,
+ rental_reimbursement_per_person_limit: null,
+ roadside_assistance_limit: null,
+ underinsured_motorist_bodily_injury_per_incident_limit: 50000,
+ underinsured_motorist_bodily_injury_per_person_limit: 25000,
+ underinsured_motorist_property_limit: null,
+ },
+ ownership: "Owned",
+ parked: "Garage",
+ purpose: "commute",
+ vehicle: {
+ style: "AWD 3.0 quattro TDI 4dr Sedan",
+ model: "A8 L",
+ price_estimate: 29084,
+ year: 2015,
+ make: "Audi",
+ },
+ vehicle_id: null,
+ vin: null,
+ },
+ ],
+ additional_drivers: [],
+ home: [
+ {
+ home_ownership: "owned",
+ },
+ ],
+ spouse_line_of_work: "Agriculture, Forestry and Fishing",
+ occupation: "Customer Service Representative",
+ id: null,
+ gender: "M",
+ credit_check_authorized: false,
+ age: "1987-11-11",
+ license_state: "Washington",
+ cash_on_hand: "$10000–14999",
+ address: {
+ city: "HOUSTON",
+ country: "US",
+ state: "TX",
+ street: "9625 GARFIELD AVE.",
+ zip: "77082",
+ },
+ spouse_education_level: "MASTERS",
+ spouse_email: "amy.stake@abc.com",
+ spouse_added_to_auto_policy: true,
+ },
+ extractedInformationSchema: {
+ additionalProperties: false,
+ properties: {
+ quotes: {
+ items: {
+ additionalProperties: false,
+ properties: {
+ coverages: {
+ items: {
+ additionalProperties: false,
+ properties: {
+ amount: {
+ description:
+ "The coverage amount in USD, which can be a single value or a range (e.g., '$300,000' or '$300,000/$300,000').",
+ type: "string",
+ },
+ included: {
+ description:
+ "Indicates whether the coverage is included in the policy (true or false).",
+ type: "boolean",
+ },
+ type: {
+ description:
+ "The limit of the coverage (e.g., 'bodily_injury_limit', 'property_damage_limit', 'underinsured_motorist_bodily_injury_limit').\nTranslate the english name of the coverage to snake case values in the following list:\n * bodily_injury_limit\n * property_damage_limit\n * underinsured_motorist_bodily_injury_limit\n * personal_injury_protection\n * accidental_death\n * work_loss_exclusion\n",
+ type: "string",
+ },
+ },
+ type: "object",
+ },
+ type: "array",
+ },
+ premium_amount: {
+ description:
+ "The total premium amount for the whole quote timeframe in USD, formatted as a string (e.g., '$321.57').",
+ type: "string",
+ },
+ quote_number: {
+ description:
+ "The quote number generated by the carrier that identifies this quote",
+ type: "string",
+ },
+ timeframe: {
+ description:
+ "The duration of the coverage, typically expressed in months or years.",
+ type: "string",
+ },
+ vehicle_coverages: {
+ items: {
+ additionalProperties: false,
+ properties: {
+ collision_deductible: {
+ description:
+ "The collision deductible amount in USD, which is a single value (e.g., '$500') or null if it is not included",
+ type: "string",
+ },
+ comprehensive_deductible: {
+ description:
+ "The collision deductible amount in USD, which is a single value (e.g., '$500') or null if it is not included",
+ type: "string",
+ },
+ for_vehicle: {
+ additionalProperties: false,
+ description:
+ "The vehicle that the collision and comprehensive coverage is for",
+ properties: {
+ make: {
+ description: "The make of the vehicle",
+ type: "string",
+ },
+ model: {
+ description: "The model of the vehicle",
+ type: "string",
+ },
+ year: {
+ description: "The year of the vehicle",
+ type: "string",
+ },
+ },
+ type: "object",
+ },
+ underinsured_property_damage: {
+ description:
+ "The underinsured property damage limit for this vehicle, which is a limit and a deductible (e.g., '$25,000/$250 deductible') or null if it is not included",
+ type: "string",
+ },
+ },
+ type: "object",
+ },
+ type: "array",
+ },
+ },
+ type: "object",
+ },
+ type: "array",
+ },
+ },
+ type: "object",
+ },
+};
+
+export function getSampleForInitialFormValues(sample: SampleCase) {
+ switch (sample) {
+ case "geico":
+ return {
+ ...geico,
+ navigationPayload: JSON.stringify(geico.navigationPayload, null, 2),
+ extractedInformationSchema: JSON.stringify(
+ geico.extractedInformationSchema,
+ null,
+ 2,
+ ),
+ };
+ case "finditparts":
+ return {
+ ...finditparts,
+ navigationPayload: JSON.stringify(
+ finditparts.navigationPayload,
+ null,
+ 2,
+ ),
+ };
+ case "california_edd":
+ return {
+ ...california_edd,
+ navigationPayload: JSON.stringify(
+ california_edd.navigationPayload,
+ null,
+ 2,
+ ),
+ };
+ case "bci_seguros":
+ return {
+ ...bci_seguros,
+ navigationPayload: JSON.stringify(
+ bci_seguros.navigationPayload,
+ null,
+ 2,
+ ),
+ };
+ }
+}
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepList.tsx b/skyvern-frontend/src/routes/tasks/detail/StepList.tsx
new file mode 100644
index 00000000..9a949c72
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepList.tsx
@@ -0,0 +1,123 @@
+import { client } from "@/api/AxiosClient";
+import { StepApiResponse } from "@/api/types";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useQuery } from "@tanstack/react-query";
+import { useParams, useSearchParams } from "react-router-dom";
+import { StepListSkeleton } from "./StepListSkeleton";
+import { TaskStatusBadge } from "@/components/TaskStatusBadge";
+import { basicTimeFormat } from "@/util/timeFormat";
+
+function StepList() {
+ const { taskId } = useParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
+
+ const {
+ data: steps,
+ isFetching,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["task", taskId, "steps", page],
+ queryFn: async () => {
+ return client
+ .get(`/tasks/${taskId}/steps`, {
+ params: {
+ page,
+ },
+ })
+ .then((response) => response.data);
+ },
+ });
+
+ if (isFetching) {
+ return ;
+ }
+
+ if (isError) {
+ return Error: {error?.message} ;
+ }
+
+ if (!steps) {
+ return No steps found ;
+ }
+
+ return (
+ <>
+
+
+
+ Order
+ Status
+ Created At
+
+
+
+ {steps.length === 0 ? (
+
+ No tasks found
+
+ ) : (
+ steps.map((step) => {
+ return (
+
+ {step.order}
+
+
+
+
+ {basicTimeFormat(step.created_at)}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ {
+ const params = new URLSearchParams();
+ params.set("page", String(Math.max(1, page - 1)));
+ setSearchParams(params);
+ }}
+ />
+
+
+ {page}
+
+
+ {
+ const params = new URLSearchParams();
+ params.set("page", String(page + 1));
+ setSearchParams(params);
+ }}
+ />
+
+
+
+ >
+ );
+}
+
+export { StepList };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx b/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx
new file mode 100644
index 00000000..2b3a9f55
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+const pageSizeArray = new Array(15).fill(null); // doesn't matter the value
+
+function StepListSkeleton() {
+ return (
+
+
+
+
+ Order
+ Status
+ Created At
+
+
+
+ {pageSizeArray.map((_, index) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export { StepListSkeleton };
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
new file mode 100644
index 00000000..0a5a415e
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
@@ -0,0 +1,142 @@
+import { client } from "@/api/AxiosClient";
+import { Status, TaskApiResponse } from "@/api/types";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { StepList } from "./StepList";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { TaskStatusBadge } from "@/components/TaskStatusBadge";
+import { artifactApiBaseUrl } from "@/util/env";
+import { Button } from "@/components/ui/button";
+import { ReloadIcon } from "@radix-ui/react-icons";
+import { basicTimeFormat } from "@/util/timeFormat";
+
+function TaskDetails() {
+ const { taskId } = useParams();
+
+ const {
+ data: task,
+ isFetching: isTaskFetching,
+ isError: isTaskError,
+ error: taskError,
+ refetch,
+ } = useQuery({
+ queryKey: ["task", taskId],
+ queryFn: async () => {
+ return client.get(`/tasks/${taskId}`).then((response) => response.data);
+ },
+ placeholderData: keepPreviousData,
+ });
+
+ if (isTaskError) {
+ return Error: {taskError?.message} ;
+ }
+
+ if (isTaskFetching) {
+ return Loading... ; // TODO: skeleton
+ }
+
+ if (!task) {
+ return Task not found ;
+ }
+
+ return (
+
+
+
+ {task.recording_url ? (
+
+
+
+
+ ) : null}
+
+
+
+
+ {task.status === Status.Completed ? (
+
+
+
+
+ ) : null}
+ {task.status === Status.Failed || task.status === Status.Terminated ? (
+
+
+
+
+ ) : null}
+
+
+
+
+ Task Parameters
+
+
+
+ Task ID: {taskId}
+ URL: {task.request.url}
+ {basicTimeFormat(task.created_at)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Task Steps
+
+
+
+ );
+}
+
+export { TaskDetails };
diff --git a/skyvern-frontend/src/routes/tasks/list/TaskList.tsx b/skyvern-frontend/src/routes/tasks/list/TaskList.tsx
new file mode 100644
index 00000000..c2676bba
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/list/TaskList.tsx
@@ -0,0 +1,151 @@
+import { client } from "@/api/AxiosClient";
+import { TaskApiResponse } from "@/api/types";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { TaskListSkeleton } from "./TaskListSkeleton";
+import { RunningTasks } from "../running/RunningTasks";
+import { cn } from "@/util/utils";
+import { PAGE_SIZE } from "../constants";
+import { TaskStatusBadge } from "@/components/TaskStatusBadge";
+import { basicTimeFormat } from "@/util/timeFormat";
+
+function TaskList() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
+
+ const {
+ data: tasks,
+ isPending,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["tasks", page],
+ queryFn: async () => {
+ return client
+ .get("/tasks", {
+ params: {
+ page,
+ page_size: PAGE_SIZE,
+ },
+ })
+ .then((response) => response.data);
+ },
+ refetchInterval: 3000,
+ placeholderData: keepPreviousData,
+ });
+
+ if (isPending) {
+ return ;
+ }
+
+ if (isError) {
+ return Error: {error?.message} ;
+ }
+
+ if (!tasks) {
+ return null;
+ }
+
+ const resolvedTasks = tasks.filter(
+ (task) =>
+ task.status === "completed" ||
+ task.status === "failed" ||
+ task.status === "terminated",
+ );
+
+ return (
+
+ Running Tasks
+
+
+
+ Task History
+
+
+
+ URL
+ Status
+ Created At
+
+
+
+ {tasks.length === 0 ? (
+
+ No tasks found
+
+ ) : (
+ resolvedTasks.map((task) => {
+ return (
+ {
+ navigate(task.task_id);
+ }}
+ >
+ {task.request.url}
+
+
+
+
+ {basicTimeFormat(task.created_at)}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ {
+ if (page === 1) {
+ return;
+ }
+ const params = new URLSearchParams();
+ params.set("page", String(Math.max(1, page - 1)));
+ setSearchParams(params);
+ }}
+ />
+
+
+ {page}
+
+
+ {
+ const params = new URLSearchParams();
+ params.set("page", String(page + 1));
+ setSearchParams(params);
+ }}
+ />
+
+
+
+
+ );
+}
+
+export { TaskList };
diff --git a/skyvern-frontend/src/routes/tasks/list/TaskListSkeleton.tsx b/skyvern-frontend/src/routes/tasks/list/TaskListSkeleton.tsx
new file mode 100644
index 00000000..60de14fb
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/list/TaskListSkeleton.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+const pageSizeArray = new Array(15).fill(null); // doesn't matter the value
+
+function TaskListSkeleton() {
+ return (
+
+
+
+
+ URL
+ Status
+ Created At
+
+
+
+ {pageSizeArray.map((_, index) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export { TaskListSkeleton };
diff --git a/skyvern-frontend/src/routes/tasks/running/RunningTaskSkeleton.tsx b/skyvern-frontend/src/routes/tasks/running/RunningTaskSkeleton.tsx
new file mode 100644
index 00000000..1a754978
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/running/RunningTaskSkeleton.tsx
@@ -0,0 +1,83 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+function RunningTaskSkeleton() {
+ // 4 cards with skeletons for each part
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export { RunningTaskSkeleton };
diff --git a/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx b/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx
new file mode 100644
index 00000000..83902eab
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx
@@ -0,0 +1,81 @@
+import { client } from "@/api/AxiosClient";
+import { Status, TaskApiResponse } from "@/api/types";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PAGE_SIZE } from "../constants";
+import { RunningTaskSkeleton } from "./RunningTaskSkeleton";
+import { basicTimeFormat } from "@/util/timeFormat";
+
+function RunningTasks() {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
+
+ const {
+ data: tasks,
+ isPending,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["tasks", page],
+ queryFn: async () => {
+ return client
+ .get("/tasks", {
+ params: {
+ page,
+ page_size: PAGE_SIZE,
+ },
+ })
+ .then((response) => response.data);
+ },
+ refetchInterval: 3000,
+ placeholderData: keepPreviousData,
+ });
+
+ if (isPending) {
+ return ;
+ }
+
+ if (isError) {
+ return Error: {error?.message} ;
+ }
+
+ if (!tasks) {
+ return null;
+ }
+
+ const runningTasks = tasks.filter((task) => task.status === Status.Running);
+
+ if (runningTasks.length === 0) {
+ return No running tasks ;
+ }
+
+ return runningTasks.map((task) => {
+ return (
+ {
+ navigate(`/tasks/${task.task_id}`);
+ }}
+ >
+
+ {task.request.url}
+
+
+ Goal: {task.request.navigation_goal}
+ Created: {basicTimeFormat(task.created_at)}
+
+ );
+ });
+}
+
+export { RunningTasks };
diff --git a/skyvern-frontend/src/routes/tasks/types.ts b/skyvern-frontend/src/routes/tasks/types.ts
new file mode 100644
index 00000000..7c737a0e
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/types.ts
@@ -0,0 +1,5 @@
+export type SampleCase =
+ | "geico"
+ | "finditparts"
+ | "california_edd"
+ | "bci_seguros";
diff --git a/skyvern-frontend/src/store/SettingsStore.ts b/skyvern-frontend/src/store/SettingsStore.ts
new file mode 100644
index 00000000..b8050b59
--- /dev/null
+++ b/skyvern-frontend/src/store/SettingsStore.ts
@@ -0,0 +1,19 @@
+import { create } from "zustand";
+
+type SettingsStore = {
+ environment: string;
+ organization: string;
+ setEnvironment: (environment: string) => void;
+ setOrganization: (organization: string) => void;
+};
+
+const useSettingsStore = create((set) => {
+ return {
+ environment: "local",
+ organization: "skyvern",
+ setEnvironment: (environment: string) => set({ environment }),
+ setOrganization: (organization: string) => set({ organization }),
+ };
+});
+
+export { useSettingsStore };
diff --git a/skyvern-frontend/src/util/env.ts b/skyvern-frontend/src/util/env.ts
new file mode 100644
index 00000000..d91660b4
--- /dev/null
+++ b/skyvern-frontend/src/util/env.ts
@@ -0,0 +1,25 @@
+const apiBaseUrl = import.meta.env.VITE_API_BASE_URL as string;
+
+if (!apiBaseUrl) {
+ console.error("apiBaseUrl environment variable was not set");
+}
+
+const environment = import.meta.env.VITE_ENVIRONMENT as string;
+
+if (!environment) {
+ console.error("environment environment variable was not set");
+}
+
+const credential = import.meta.env.VITE_API_CREDENTIAL as string;
+
+if (!credential) {
+ console.error("credential environment variable was not set");
+}
+
+const artifactApiBaseUrl = import.meta.env.VITE_ARTIFACT_API_BASE_URL as string;
+
+if (!artifactApiBaseUrl) {
+ console.error("artifactApiBaseUrl environment variable was not set");
+}
+
+export { apiBaseUrl, environment, credential, artifactApiBaseUrl };
diff --git a/skyvern-frontend/src/util/timeFormat.ts b/skyvern-frontend/src/util/timeFormat.ts
new file mode 100644
index 00000000..d140cafc
--- /dev/null
+++ b/skyvern-frontend/src/util/timeFormat.ts
@@ -0,0 +1,13 @@
+function basicTimeFormat(time: string): string {
+ const date = new Date(time);
+ const dateString = date.toLocaleDateString("en-us", {
+ weekday: "long",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ const timeString = date.toLocaleTimeString("en-us");
+ return `${dateString} at ${timeString}`;
+}
+
+export { basicTimeFormat };
diff --git a/skyvern-frontend/vite.config.ts b/skyvern-frontend/vite.config.ts
index 72d95969..030e6b9c 100644
--- a/skyvern-frontend/vite.config.ts
+++ b/skyvern-frontend/vite.config.ts
@@ -5,6 +5,12 @@ import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ port: 8080,
+ },
+ preview: {
+ port: 8080,
+ },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
|