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 ? ( -
- -