diff --git a/skyvern-frontend/src/api/QueryClient.ts b/skyvern-frontend/src/api/QueryClient.ts
index c88acdb1..80962982 100644
--- a/skyvern-frontend/src/api/QueryClient.ts
+++ b/skyvern-frontend/src/api/QueryClient.ts
@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- staleTime: 0,
+ staleTime: Infinity,
retry: false,
},
},
diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts
index 8e0e1a17..710f7c9b 100644
--- a/skyvern-frontend/src/api/types.ts
+++ b/skyvern-frontend/src/api/types.ts
@@ -37,6 +37,13 @@ export type ArtifactApiResponse = {
organization_id: string;
};
+export type ActionAndResultApiResponse = [
+ ActionApiResponse,
+ {
+ success: boolean;
+ },
+];
+
export type StepApiResponse = {
step_id: string;
task_id: string;
@@ -47,8 +54,7 @@ export type StepApiResponse = {
order: number;
organization_id: string;
output: {
- action_results: unknown[];
- actions_and_results: unknown[];
+ actions_and_results: ActionAndResultApiResponse[];
errors: unknown[];
};
retry_index: number;
@@ -149,3 +155,39 @@ export type WorkflowApiResponse = {
modified_at: string;
deleted_at: string | null;
};
+
+// TODO complete this
+export const ActionTypes = {
+ InputText: "input_text",
+ Click: "click",
+ SelectOption: "select_option",
+ UploadFile: "upload_file",
+ complete: "complete",
+} as const;
+
+export type ActionType = (typeof ActionTypes)[keyof typeof ActionTypes];
+
+export type Option = {
+ label: string;
+ index: number;
+ value: string;
+};
+
+export type ActionApiResponse = {
+ reasoning: string;
+ confidence_float: number;
+ action_type: ActionType;
+ text: string | null;
+ option: Option | null;
+ file_url: string | null;
+};
+
+export type Action = {
+ reasoning: string;
+ confidence: number;
+ type: ActionType;
+ input: string;
+ success: boolean;
+ stepId: string;
+ index: number;
+};
diff --git a/skyvern-frontend/src/components/StatusBadge.tsx b/skyvern-frontend/src/components/StatusBadge.tsx
index 201c2b16..b92eba88 100644
--- a/skyvern-frontend/src/components/StatusBadge.tsx
+++ b/skyvern-frontend/src/components/StatusBadge.tsx
@@ -21,7 +21,11 @@ function StatusBadge({ status }: Props) {
const statusText = status === "timed_out" ? "timed out" : status;
- return {statusText};
+ return (
+
+ {statusText}
+
+ );
}
export { StatusBadge };
diff --git a/skyvern-frontend/src/components/ZoomableImage.tsx b/skyvern-frontend/src/components/ZoomableImage.tsx
index b4e8703c..d7e62585 100644
--- a/skyvern-frontend/src/components/ZoomableImage.tsx
+++ b/skyvern-frontend/src/components/ZoomableImage.tsx
@@ -33,7 +33,7 @@ function ZoomableImage(props: HTMLImageElementProps) {
{modalOpen && (
["onClick"];
+ selected: boolean;
+ onMouseEnter: React.DOMAttributes
["onMouseEnter"];
+};
+
+function ActionCard({
+ title,
+ description,
+ selected,
+ onClick,
+ onMouseEnter,
+}: Props) {
+ return (
+
+
+
{title}
+
{description}
+
+
+ );
+}
+
+export { ActionCard };
diff --git a/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx b/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx
new file mode 100644
index 00000000..4f522b86
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx
@@ -0,0 +1,53 @@
+import { getClient } from "@/api/AxiosClient";
+import { ArtifactApiResponse, ArtifactType } from "@/api/types";
+import { ZoomableImage } from "@/components/ZoomableImage";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { getImageURL } from "./artifactUtils";
+import { ReloadIcon } from "@radix-ui/react-icons";
+
+type Props = {
+ stepId: string;
+ index: number;
+};
+
+function ActionScreenshot({ stepId, index }: Props) {
+ const { taskId } = useParams();
+ const credentialGetter = useCredentialGetter();
+
+ const { data: artifacts, isFetching } = useQuery>({
+ queryKey: ["task", taskId, "steps", stepId, "artifacts"],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client
+ .get(`/tasks/${taskId}/steps/${stepId}/artifacts`)
+ .then((response) => response.data);
+ },
+ });
+
+ const actionScreenshots = artifacts?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
+ );
+
+ const screenshot = actionScreenshots?.[index];
+
+ if (isFetching) {
+ return (
+
+
+
Loading screenshot...
+
+ );
+ }
+
+ return screenshot ? (
+
+
+
+ ) : (
+ Screenshot not found
+ );
+}
+
+export { ActionScreenshot };
diff --git a/skyvern-frontend/src/routes/tasks/detail/InputReasoningCard.tsx b/skyvern-frontend/src/routes/tasks/detail/InputReasoningCard.tsx
new file mode 100644
index 00000000..8acc713b
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/InputReasoningCard.tsx
@@ -0,0 +1,25 @@
+type Props = {
+ input: string;
+ reasoning: string;
+ confidence: number;
+};
+
+function InputReasoningCard({ input, reasoning, confidence }: Props) {
+ return (
+
+
+
+ Agent Input: {input}
+
+
+ Agent Reasoning: {reasoning}
+
+
+
+ Confidence: {confidence}
+
+
+ );
+}
+
+export { InputReasoningCard };
diff --git a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx
new file mode 100644
index 00000000..e2d46aa2
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx
@@ -0,0 +1,107 @@
+import { Action } from "@/api/types";
+import { Button } from "@/components/ui/button";
+import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons";
+import { useQueryClient } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { getClient } from "@/api/AxiosClient";
+import { useEffect, useRef } from "react";
+import { cn } from "@/util/utils";
+
+type Props = {
+ data: Array;
+ onNext: () => void;
+ onPrevious: () => void;
+ onActiveIndexChange: (index: number) => void;
+ activeIndex: number;
+};
+
+function ScrollableActionList({
+ data,
+ onNext,
+ onPrevious,
+ activeIndex,
+ onActiveIndexChange,
+}: Props) {
+ const { taskId } = useParams();
+ const queryClient = useQueryClient();
+ const credentialGetter = useCredentialGetter();
+ const refs = useRef>(
+ Array.from({ length: data.length }),
+ );
+
+ useEffect(() => {
+ if (refs.current[activeIndex]) {
+ refs.current[activeIndex]?.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ });
+ }
+ }, [activeIndex]);
+
+ return (
+
+
+
+ {activeIndex + 1} of {data.length} total actions
+
+
+
+ {data.map((action, index) => {
+ if (!action) {
+ return null;
+ }
+ const selected = activeIndex === index;
+ return (
+
{
+ refs.current[index] = element;
+ }}
+ className={cn(
+ "flex p-4 rounded-lg shadow-md border hover:bg-muted cursor-pointer",
+ {
+ "bg-muted": selected,
+ },
+ )}
+ onClick={() => onActiveIndexChange(index)}
+ onMouseEnter={() => {
+ queryClient.prefetchQuery({
+ queryKey: [
+ "task",
+ taskId,
+ "steps",
+ action.stepId,
+ "artifacts",
+ ],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client
+ .get(`/tasks/${taskId}/steps/${action.stepId}/artifacts`)
+ .then((response) => response.data);
+ },
+ staleTime: Infinity,
+ });
+ }}
+ >
+
+
{`Action ${index + 1}`}
+
{action.type}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export { ScrollableActionList };
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx
new file mode 100644
index 00000000..54fd4b0f
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import { ActionScreenshot } from "./ActionScreenshot";
+import { InputReasoningCard } from "./InputReasoningCard";
+import { ScrollableActionList } from "./ScrollableActionList";
+import { useActions } from "./useActions";
+
+function TaskActions() {
+ const { taskId } = useParams();
+
+ const { data, isFetching } = useActions(taskId!);
+ const [selectedActionIndex, setSelectedAction] = useState(0);
+
+ const activeAction = data?.[selectedActionIndex];
+
+ if (isFetching || !data) {
+ return Loading...
;
+ }
+
+ if (!activeAction) {
+ return No action
;
+ }
+
+ return (
+
+
+
+ setSelectedAction((prev) =>
+ prev === data.length - 1 ? prev : prev + 1,
+ )
+ }
+ onPrevious={() =>
+ setSelectedAction((prev) => (prev === 0 ? prev : prev - 1))
+ }
+ />
+
+ );
+}
+
+export { TaskActions };
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
index b8f96e8d..18e404d5 100644
--- a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
@@ -1,26 +1,14 @@
import { getClient } 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 { StatusBadge } from "@/components/StatusBadge";
-import { basicTimeFormat } from "@/util/timeFormat";
-import { StepArtifactsLayout } from "./StepArtifactsLayout";
-import { getRecordingURL, getScreenshotURL } from "./artifactUtils";
-import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
-import { ZoomableImage } from "@/components/ZoomableImage";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Separator } from "@/components/ui/separator";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { cn } from "@/util/utils";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { NavLink, Outlet, useParams } from "react-router-dom";
function TaskDetails() {
const { taskId } = useParams();
@@ -28,8 +16,8 @@ function TaskDetails() {
const {
data: task,
- isFetching: isTaskFetching,
- isError: isTaskError,
+ isFetching: taskIsFetching,
+ isError: taskIsError,
error: taskError,
} = useQuery({
queryKey: ["task", taskId, "details"],
@@ -49,171 +37,89 @@ function TaskDetails() {
placeholderData: keepPreviousData,
});
- if (isTaskError) {
+ if (taskIsError) {
return Error: {taskError?.message}
;
}
return (
-
-
-
-
-
-
- {isTaskFetching ? (
+
+
+ {taskIsFetching ? (
) : task ? (
) : null}
- {task?.status === Status.Completed ? (
-
-
-
+
+ {task?.status === Status.Completed ? (
+
+
+
+
+ ) : null}
+ {task?.status === Status.Failed ||
+ task?.status === Status.Terminated ? (
+
+
+
+
+ ) : null}
+
+
+
+ {
+ return cn(
+ "cursor-pointer px-2 py-1 rounded-md text-muted-foreground",
+ {
+ "bg-primary-foreground text-foreground": isActive,
+ },
+ );
+ }}
+ >
+ Actions
+
+ {
+ return cn(
+ "cursor-pointer px-2 py-1 rounded-md text-muted-foreground",
+ {
+ "bg-primary-foreground text-foreground": isActive,
+ },
+ );
+ }}
+ >
+ Recording
+
+ {
+ return cn(
+ "cursor-pointer px-2 py-1 rounded-md text-muted-foreground",
+ {
+ "bg-primary-foreground text-foreground": isActive,
+ },
+ );
+ }}
+ >
+ Parameters
+
- ) : null}
- {task?.status === Status.Failed || task?.status === Status.Terminated ? (
-
-
-
-
- ) : null}
- {task ? (
-
-
- Task Artifacts
-
- Recording and final screenshot of the task
-
-
-
-
-
- Recording
-
- Final Screenshot
-
-
-
- {task.recording_url ? (
-
- ) : (
- No recording available
- )}
-
-
- {task ? (
-
- {task.screenshot_url ? (
-
- ) : (
-
No screenshot available
- )}
-
- ) : null}
-
-
-
-
- ) : null}
-
-
- Steps
- Task Steps and Step Artifacts
-
-
-
-
-
-
-
- Parameters
- Task URL and Input Parameters
-
-
- {task ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : null}
-
-
+
+
);
}
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskParameters.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskParameters.tsx
new file mode 100644
index 00000000..284b444c
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskParameters.tsx
@@ -0,0 +1,113 @@
+import { getClient } from "@/api/AxiosClient";
+import { TaskApiResponse } from "@/api/types";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { basicTimeFormat } from "@/util/timeFormat";
+import { Label, Separator } from "@radix-ui/react-dropdown-menu";
+import { useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+
+function TaskParameters() {
+ const { taskId } = useParams();
+ const credentialGetter = useCredentialGetter();
+ const {
+ data: task,
+ isFetching: taskIsFetching,
+ isError: taskIsError,
+ } = useQuery
({
+ queryKey: ["task", taskId],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client.get(`/tasks/${taskId}`).then((response) => response.data);
+ },
+ });
+
+ if (taskIsFetching) {
+ return Loading parameters...
;
+ }
+
+ if (taskIsError || !task) {
+ return Error loading parameters
;
+ }
+
+ return (
+
+
+ Parameters
+ Task URL and Input Parameters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { TaskParameters };
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskRecording.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskRecording.tsx
new file mode 100644
index 00000000..a5773567
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskRecording.tsx
@@ -0,0 +1,45 @@
+import { getClient } from "@/api/AxiosClient";
+import { TaskApiResponse } from "@/api/types";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { useQuery } from "@tanstack/react-query";
+import { getRecordingURL } from "./artifactUtils";
+import { useParams } from "react-router-dom";
+
+function TaskRecording() {
+ const { taskId } = useParams();
+ const credentialGetter = useCredentialGetter();
+
+ const {
+ data: task,
+ isFetching: taskIsFetching,
+ isError: taskIsError,
+ } = useQuery({
+ queryKey: ["task", taskId],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client.get(`/tasks/${taskId}`).then((response) => response.data);
+ },
+ });
+
+ if (taskIsFetching) {
+ return Loading recording...
;
+ }
+
+ if (taskIsError || !task) {
+ return Error loading recording
;
+ }
+
+ console.log(task);
+
+ return (
+
+ {task.recording_url ? (
+
+ ) : (
+
No recording available
+ )}
+
+ );
+}
+
+export { TaskRecording };
diff --git a/skyvern-frontend/src/routes/tasks/detail/useActions.tsx b/skyvern-frontend/src/routes/tasks/detail/useActions.tsx
new file mode 100644
index 00000000..6ba5dbdd
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/useActions.tsx
@@ -0,0 +1,76 @@
+import { getClient } from "@/api/AxiosClient";
+import {
+ Action,
+ ActionApiResponse,
+ ActionTypes,
+ Status,
+ StepApiResponse,
+ TaskApiResponse,
+} from "@/api/types";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { useQuery } from "@tanstack/react-query";
+
+function getActionInput(action: ActionApiResponse) {
+ let input = "";
+ if (action.action_type === ActionTypes.InputText && action.text) {
+ input = action.text;
+ } else if (action.action_type === ActionTypes.Click) {
+ input = "Click";
+ } else if (action.action_type === ActionTypes.SelectOption && action.option) {
+ input = action.option.label;
+ }
+ return input;
+}
+
+function useActions(
+ taskId: string,
+): ReturnType>> {
+ const credentialGetter = useCredentialGetter();
+ const { data: task } = useQuery({
+ queryKey: ["task", taskId],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client.get(`/tasks/${taskId}`).then((response) => response.data);
+ },
+ });
+
+ const taskIsRunningOrQueued =
+ task?.status === Status.Running || task?.status === Status.Queued;
+
+ const useQueryReturn = useQuery>({
+ queryKey: ["task", taskId, "actions"],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ const steps = (await client
+ .get(`/tasks/${taskId}/steps`)
+ .then((response) => response.data)) as Array;
+
+ const actions = steps.map((step) => {
+ const actionsAndResults = step.output.actions_and_results;
+
+ const actions = actionsAndResults.map((actionAndResult, index) => {
+ const action: Action = {
+ reasoning: actionAndResult[0].reasoning,
+ confidence: actionAndResult[0].confidence_float,
+ input: getActionInput(actionAndResult[0]),
+ type: actionAndResult[0].action_type,
+ success: actionAndResult[1].success,
+ stepId: step.step_id,
+ index,
+ };
+ return action;
+ });
+ return actions;
+ });
+
+ return actions.flat();
+ },
+ enabled: !!task,
+ staleTime: taskIsRunningOrQueued ? 30 : Infinity,
+ refetchOnWindowFocus: taskIsRunningOrQueued,
+ });
+
+ return useQueryReturn;
+}
+
+export { useActions };
diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py
index e4140540..9405bdcb 100644
--- a/skyvern/forge/sdk/workflow/service.py
+++ b/skyvern/forge/sdk/workflow/service.py
@@ -970,7 +970,6 @@ class WorkflowService:
max_steps_per_run=block_yaml.max_steps_per_run,
max_retries=block_yaml.max_retries,
complete_on_download=block_yaml.complete_on_download,
- continue_on_failure=block_yaml.continue_on_failure,
)
elif block_yaml.block_type == BlockType.FOR_LOOP:
loop_blocks = [