;
+ }
+ case "code": {
+ return
;
+ }
+ case "download_to_s3": {
+ return
;
+ }
+ case "extraction": {
+ return
;
+ }
+ case "file_download": {
+ return
;
+ }
+ case "file_url_parser": {
+ return
;
+ }
+ case "for_loop": {
+ return
;
+ }
+ case "login": {
+ return
;
+ }
+ case "navigation": {
+ return
;
+ }
+ case "send_email": {
+ return
;
+ }
+ case "task": {
+ return
;
+ }
+ case "text_prompt": {
+ return
;
+ }
+ case "upload_to_s3": {
+ return
;
+ }
+ case "validation": {
+ return
;
+ }
+ case "wait": {
+ return
;
+ }
+ }
+}
+
+export { WorkflowBlockIcon };
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts
index d4c66bfe..2409c5ae 100644
--- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts
+++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts
@@ -1,3 +1,5 @@
+import { WorkflowBlockType } from "../../types/workflowTypes";
+
export type NodeBaseData = {
label: string;
continueOnFailure: boolean;
@@ -14,3 +16,23 @@ export const dataSchemaExampleValue = {
sample: { type: "string" },
},
} as const;
+
+export const workflowBlockTitle: {
+ [blockType in WorkflowBlockType]: string;
+} = {
+ action: "Action",
+ code: "Code",
+ download_to_s3: "Download",
+ extraction: "Extraction",
+ file_download: "File Download",
+ file_url_parser: "File Parser",
+ for_loop: "Loop",
+ login: "Login",
+ navigation: "Navigation",
+ send_email: "Send Email",
+ task: "Task",
+ text_prompt: "Text Prompt",
+ upload_to_s3: "Upload",
+ validation: "Validation",
+ wait: "Wait",
+};
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx
index 81d12c7b..ceb521b3 100644
--- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx
@@ -1,24 +1,10 @@
-import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
-import {
- CheckCircledIcon,
- Cross2Icon,
- CursorTextIcon,
- DownloadIcon,
- EnvelopeClosedIcon,
- FileIcon,
- ListBulletIcon,
- LockOpen1Icon,
- PlusIcon,
- StopwatchIcon,
- UpdateIcon,
- UploadIcon,
-} from "@radix-ui/react-icons";
-import { WorkflowBlockNode } from "../nodes";
-import { AddNodeProps } from "../FlowRenderer";
-import { ClickIcon } from "@/components/icons/ClickIcon";
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
-import { RobotIcon } from "@/components/icons/RobotIcon";
-import { ExtractIcon } from "@/components/icons/ExtractIcon";
+import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
+import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons";
+import { WorkflowBlockTypes } from "../../types/workflowTypes";
+import { AddNodeProps } from "../FlowRenderer";
+import { WorkflowBlockNode } from "../nodes";
+import { WorkflowBlockIcon } from "../nodes/WorkflowBlockIcon";
const nodeLibraryItems: Array<{
nodeType: NonNullable
;
@@ -28,93 +14,166 @@ const nodeLibraryItems: Array<{
}> = [
{
nodeType: "navigation",
- icon: ,
+ icon: (
+
+ ),
title: "Navigation Block",
description: "Navigate on the page",
},
{
nodeType: "action",
- icon: ,
+ icon: (
+
+ ),
title: "Action Block",
description: "Take a single action",
},
{
nodeType: "extraction",
- icon: ,
+ icon: (
+
+ ),
title: "Extraction Block",
description: "Extract data from the page",
},
{
nodeType: "validation",
- icon: ,
+ icon: (
+
+ ),
title: "Validation Block",
description: "Validate the state of the workflow or terminate",
},
{
nodeType: "task",
- icon: ,
+ icon: (
+
+ ),
title: "Task Block",
description: "Takes actions or extracts information",
},
{
nodeType: "textPrompt",
- icon: ,
+ icon: (
+
+ ),
title: "Text Prompt Block",
description: "Generates AI response",
},
{
nodeType: "sendEmail",
- icon: ,
+ icon: (
+
+ ),
title: "Send Email Block",
description: "Sends an email",
},
{
nodeType: "loop",
- icon: ,
+ icon: (
+
+ ),
title: "For Loop Block",
description: "Repeats nested elements",
},
// temporarily removed
// {
// nodeType: "codeBlock",
- // icon: ,
+ // icon: ,
// title: "Code Block",
// description: "Executes Python code",
// },
{
nodeType: "fileParser",
- icon: ,
+ icon: (
+
+ ),
title: "File Parser Block",
description: "Downloads and parses a file",
},
// disabled
// {
// nodeType: "download",
- // icon: ,
+ // icon: (
+ //
+ // ),
// title: "Download Block",
// description: "Downloads a file from S3",
// },
{
nodeType: "upload",
- icon: ,
+ icon: (
+
+ ),
title: "Upload Block",
description: "Uploads a file to S3",
},
{
nodeType: "fileDownload",
- icon: ,
+ icon: (
+
+ ),
title: "File Download Block",
description: "Download a file",
},
{
nodeType: "login",
- icon: ,
+ icon: (
+
+ ),
title: "Login Block",
description: "Login to a website",
},
{
nodeType: "wait",
- icon: ,
+ icon: (
+
+ ),
title: "Wait Block",
description: "Wait for some time",
},
diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts
new file mode 100644
index 00000000..7bda8515
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts
@@ -0,0 +1,32 @@
+import { getClient } from "@/api/AxiosClient";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { statusIsNotFinalized } from "@/routes/tasks/types";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { WorkflowRunTimelineItem } from "../types/workflowRunTypes";
+import { useWorkflowRunQuery } from "./useWorkflowRunQuery";
+
+function useWorkflowRunTimelineQuery() {
+ const { workflowRunId, workflowPermanentId } = useParams();
+ const credentialGetter = useCredentialGetter();
+ const { data: workflowRun } = useWorkflowRunQuery();
+
+ return useQuery>({
+ queryKey: ["workflowRunTimeline", workflowPermanentId, workflowRunId],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client
+ .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}/timeline`)
+ .then((response) => response.data);
+ },
+ refetchInterval:
+ workflowRun && statusIsNotFinalized(workflowRun) ? 5000 : false,
+ placeholderData: keepPreviousData,
+ refetchOnMount:
+ workflowRun && statusIsNotFinalized(workflowRun) ? "always" : false,
+ refetchOnWindowFocus:
+ workflowRun && statusIsNotFinalized(workflowRun) ? "always" : false,
+ });
+}
+
+export { useWorkflowRunTimelineQuery };
diff --git a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts
new file mode 100644
index 00000000..48e0ef76
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts
@@ -0,0 +1,138 @@
+import { ActionsApiResponse, Status } from "@/api/types";
+import { isTaskVariantBlock, WorkflowBlockType } from "./workflowTypes";
+
+export const WorkflowRunTimelineItemTypes = {
+ Thought: "thought",
+ Block: "block",
+} as const;
+
+export type WorkflowRunTimelineItemType =
+ (typeof WorkflowRunTimelineItemTypes)[keyof typeof WorkflowRunTimelineItemTypes];
+
+export type ObserverThought = {
+ observer_thought_id: string;
+ user_input: string | null;
+ observation: string | null;
+ thought: string | null;
+ answer: string | null;
+ created_at: string;
+ modified_at: string;
+};
+
+export type WorkflowRunBlock = {
+ workflow_run_block_id: string;
+ workflow_run_id: string;
+ parent_workflow_run_block_id: string | null;
+ block_type: WorkflowBlockType;
+ label: string | null;
+ title: string | null;
+ status: Status | null;
+ failure_reason: string | null;
+ output: object | Array | string | null;
+ continue_on_failure: boolean;
+ task_id: string | null;
+ url: string | null;
+ navigation_goal: string | null;
+ navigation_payload: Record | null;
+ data_extraction_goal: string | null;
+ data_schema: object | Array | string | null;
+ terminate_criterion: string | null;
+ complete_criterion: string | null;
+ actions: Array | null;
+ recipients?: Array | null;
+ attachments?: Array | null;
+ subject?: string | null;
+ body?: string | null;
+ prompt?: string | null;
+ wait_sec?: number | null;
+ created_at: string;
+ modified_at: string;
+};
+
+export type WorkflowRunTimelineBlockItem = {
+ type: "block";
+ block: WorkflowRunBlock;
+ children: Array;
+ thought: null;
+ created_at: string;
+ modified_at: string;
+};
+
+export type WorkflowRunTimelineThoughtItem = {
+ type: "thought";
+ block: null;
+ children: Array;
+ thought: ObserverThought;
+ created_at: string;
+ modified_at: string;
+};
+
+export type WorkflowRunTimelineItem =
+ | WorkflowRunTimelineBlockItem
+ | WorkflowRunTimelineThoughtItem;
+
+export function isThoughtItem(
+ item: unknown,
+): item is WorkflowRunTimelineThoughtItem {
+ return (
+ typeof item === "object" &&
+ item !== null &&
+ "type" in item &&
+ item.type === "thought" &&
+ "thought" in item &&
+ item.thought !== null
+ );
+}
+
+export function isBlockItem(
+ item: unknown,
+): item is WorkflowRunTimelineBlockItem {
+ return (
+ typeof item === "object" &&
+ item !== null &&
+ "type" in item &&
+ item.type === "block" &&
+ "block" in item &&
+ item.block !== null
+ );
+}
+
+export function isTaskVariantBlockItem(item: unknown) {
+ return isBlockItem(item) && isTaskVariantBlock(item.block);
+}
+
+export function isWorkflowRunBlock(item: unknown): item is WorkflowRunBlock {
+ return (
+ typeof item === "object" &&
+ item !== null &&
+ "block_type" in item &&
+ "workflow_run_block_id" in item
+ );
+}
+
+export function isObserverThought(item: unknown): item is ObserverThought {
+ return (
+ typeof item === "object" &&
+ item !== null &&
+ "observer_thought_id" in item &&
+ "thought" in item
+ );
+}
+
+export function isAction(item: unknown): item is ActionsApiResponse {
+ return typeof item === "object" && item !== null && "action_id" in item;
+}
+
+export function hasExtractedInformation(
+ item: unknown,
+): item is { extracted_information: unknown } {
+ return (
+ item !== null && typeof item === "object" && "extracted_information" in item
+ );
+}
+
+export function hasNavigationGoal(
+ item: unknown,
+): item is { navigation_goal: unknown } {
+ return item !== null && typeof item === "object" && "navigation_goal" in item;
+}
diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts
index 12942628..7a9d44e3 100644
--- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts
+++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts
@@ -120,7 +120,7 @@ export type WorkflowBlock =
| WaitBlock
| FileDownloadBlock;
-export const WorkflowBlockType = {
+export const WorkflowBlockTypes = {
Task: "task",
ForLoop: "for_loop",
Code: "code",
@@ -138,8 +138,22 @@ export const WorkflowBlockType = {
FileDownload: "file_download",
} as const;
+export function isTaskVariantBlock(item: {
+ block_type: WorkflowBlockType;
+}): boolean {
+ return (
+ item.block_type === "task" ||
+ item.block_type === "navigation" ||
+ item.block_type === "action" ||
+ item.block_type === "extraction" ||
+ item.block_type === "validation" ||
+ item.block_type === "login" ||
+ item.block_type === "file_download"
+ );
+}
+
export type WorkflowBlockType =
- (typeof WorkflowBlockType)[keyof typeof WorkflowBlockType];
+ (typeof WorkflowBlockTypes)[keyof typeof WorkflowBlockTypes];
export type WorkflowBlockBase = {
label: string;
diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts
index ad865a65..062ffedd 100644
--- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts
+++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts
@@ -1,3 +1,5 @@
+import { WorkflowBlockType } from "./workflowTypes";
+
export type WorkflowCreateYAMLRequest = {
title: string;
description?: string | null;
@@ -67,26 +69,6 @@ export type OutputParameterYAML = ParameterYAMLBase & {
parameter_type: "output";
};
-const BlockTypes = {
- TASK: "task",
- FOR_LOOP: "for_loop",
- CODE: "code",
- TEXT_PROMPT: "text_prompt",
- DOWNLOAD_TO_S3: "download_to_s3",
- UPLOAD_TO_S3: "upload_to_s3",
- SEND_EMAIL: "send_email",
- FILE_URL_PARSER: "file_url_parser",
- VALIDATION: "validation",
- ACTION: "action",
- NAVIGATION: "navigation",
- EXTRACTION: "extraction",
- LOGIN: "login",
- WAIT: "wait",
- FILE_DOWNLOAD: "file_download",
-} as const;
-
-export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
-
export type BlockYAML =
| TaskBlockYAML
| CodeBlockYAML
@@ -105,7 +87,7 @@ export type BlockYAML =
| FileDownloadBlockYAML;
export type BlockYAMLBase = {
- block_type: BlockType;
+ block_type: WorkflowBlockType;
label: string;
continue_on_failure?: boolean;
};
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx
new file mode 100644
index 00000000..17d42478
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx
@@ -0,0 +1,63 @@
+import { ActionsApiResponse, ActionTypes, Status } from "@/api/types";
+import { Separator } from "@/components/ui/separator";
+import { ActionTypePill } from "@/routes/tasks/detail/ActionTypePill";
+import { cn } from "@/util/utils";
+import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons";
+
+type Props = {
+ action: ActionsApiResponse;
+ index: number;
+ active: boolean;
+ onClick: () => void;
+};
+
+function ActionCard({ action, onClick, active, index }: Props) {
+ const success = action.status === Status.Completed;
+
+ return (
+
+
+
+
+ #{index}
+
+
+
+ {success ? (
+
+
+ Success
+
+ ) : (
+
+
+ Fail
+
+ )}
+
+
+
{action.reasoning}
+ {action.action_type === ActionTypes.InputText && (
+ <>
+
+
+ Input: {action.response}
+
+ >
+ )}
+
+
+ );
+}
+
+export { ActionCard };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx
new file mode 100644
index 00000000..e98da892
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx
@@ -0,0 +1,41 @@
+import { CubeIcon } from "@radix-ui/react-icons";
+import { WorkflowRunBlock } from "../types/workflowRunTypes";
+import { cn } from "@/util/utils";
+import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
+import { workflowBlockTitle } from "../editor/nodes/types";
+
+type Props = {
+ active: boolean;
+ block: WorkflowRunBlock;
+ onClick: () => void;
+};
+
+function BlockCard({ block, onClick, active }: Props) {
+ return (
+
+
+
+
+ {workflowBlockTitle[block.block_type]}
+
+
+
+ Block
+
+
+
+ );
+}
+
+export { BlockCard };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx
new file mode 100644
index 00000000..705e7321
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx
@@ -0,0 +1,76 @@
+import { getClient } from "@/api/AxiosClient";
+import { ArtifactApiResponse, ArtifactType, Status } from "@/api/types";
+import { ZoomableImage } from "@/components/ZoomableImage";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { useQuery } from "@tanstack/react-query";
+import { ReloadIcon } from "@radix-ui/react-icons";
+import { statusIsNotFinalized } from "@/routes/tasks/types";
+import { getImageURL } from "@/routes/tasks/detail/artifactUtils";
+
+type Props = {
+ observerThoughtId: string;
+ taskStatus?: Status; // to give a hint that screenshot may not be available if task is not finalized
+};
+
+function ObserverThoughtScreenshot({ observerThoughtId, taskStatus }: Props) {
+ const credentialGetter = useCredentialGetter();
+
+ const { data: artifacts, isLoading } = useQuery>({
+ queryKey: ["observerThought", observerThoughtId, "artifacts"],
+ queryFn: async () => {
+ const client = await getClient(credentialGetter);
+ return client
+ .get(`/observer_thought/${observerThoughtId}/artifacts`)
+ .then((response) => response.data);
+ },
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ const screenshot = data?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
+ )?.[0];
+ if (!screenshot) {
+ return 5000;
+ }
+ return false;
+ },
+ });
+
+ const llmScreenshots = artifacts?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
+ );
+
+ const screenshot = llmScreenshots?.[0];
+
+ if (isLoading) {
+ return (
+
+
+
Loading screenshot...
+
+ );
+ }
+
+ if (
+ !screenshot &&
+ taskStatus &&
+ statusIsNotFinalized({ status: taskStatus })
+ ) {
+ return The screenshot for this action is not available yet.
;
+ }
+
+ if (!screenshot) {
+ return (
+
+ No screenshot found for this action.
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { ObserverThoughtScreenshot };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx
new file mode 100644
index 00000000..cc9d9c5d
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx
@@ -0,0 +1,36 @@
+import { PersonIcon } from "@radix-ui/react-icons";
+import { ObserverThought } from "../types/workflowRunTypes";
+import { cn } from "@/util/utils";
+
+type Props = {
+ active: boolean;
+ thought: ObserverThought;
+ onClick: (thought: ObserverThought) => void;
+};
+
+function ThoughtCard({ thought, onClick, active }: Props) {
+ return (
+ {
+ onClick(thought);
+ }}
+ >
+
+
{thought.answer}
+
+ );
+}
+
+export { ThoughtCard };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx
new file mode 100644
index 00000000..7ae3f212
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx
@@ -0,0 +1,171 @@
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
+import { WorkflowRunOverviewSkeleton } from "./WorkflowRunOverviewSkeleton";
+import { useState } from "react";
+import { statusIsNotFinalized } from "@/routes/tasks/types";
+import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
+import { WorkflowRunStream } from "./WorkflowRunStream";
+import { ActionScreenshot } from "@/routes/tasks/detail/ActionScreenshot";
+import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem";
+import { ThoughtCard } from "./ThoughtCard";
+import {
+ isAction,
+ isBlockItem,
+ isObserverThought,
+ isThoughtItem,
+ isWorkflowRunBlock,
+ ObserverThought,
+ WorkflowRunBlock,
+} from "../types/workflowRunTypes";
+import { ActionsApiResponse } from "@/api/types";
+import { cn } from "@/util/utils";
+import { DotFilledIcon } from "@radix-ui/react-icons";
+import { WorkflowRunTimelineItemInfoSection } from "./WorkflowRunTimelineItemInfoSection";
+import { ObserverThoughtScreenshot } from "./ObserverThoughtScreenshot";
+import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
+
+export type WorkflowRunOverviewActiveElement =
+ | ActionsApiResponse
+ | ObserverThought
+ | WorkflowRunBlock
+ | "stream"
+ | null;
+
+function WorkflowRunOverview() {
+ const [active, setActive] = useState(null);
+ const { data: workflowRun, isLoading: workflowRunIsLoading } =
+ useWorkflowRunQuery();
+
+ const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } =
+ useWorkflowRunTimelineQuery();
+
+ if (workflowRunIsLoading || workflowRunTimelineIsLoading) {
+ return ;
+ }
+
+ if (!workflowRun) {
+ return null;
+ }
+
+ if (typeof workflowRunTimeline === "undefined") {
+ return null;
+ }
+
+ const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun);
+
+ const timeline = workflowRunTimeline.slice().reverse();
+
+ function getActiveSelection(): WorkflowRunOverviewActiveElement {
+ if (active === null) {
+ if (workflowRunIsNotFinalized) {
+ return "stream";
+ }
+ if (timeline!.length > 0) {
+ const timelineItem = timeline![0];
+ if (isBlockItem(timelineItem)) {
+ if (
+ timelineItem.block.actions &&
+ timelineItem.block.actions.length > 0
+ ) {
+ return timelineItem.block
+ .actions[0] as WorkflowRunOverviewActiveElement;
+ }
+ return timelineItem.block;
+ }
+ if (isThoughtItem(timelineItem)) {
+ return timelineItem.thought;
+ }
+ }
+ }
+ return active;
+ }
+
+ const selection = getActiveSelection();
+
+ return (
+
+
+
+ {selection === "stream" && }
+ {selection !== "stream" && isAction(selection) && (
+
+ )}
+ {isWorkflowRunBlock(selection) && (
+
+ No screenshot found for this block
+
+ )}
+ {isObserverThought(selection) && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {workflowRunIsNotFinalized && (
+
setActive("stream")}
+ >
+
+
+ Live
+
+
+ )}
+ {timeline.length === 0 &&
Workflow timeline is empty
}
+ {timeline?.map((timelineItem) => {
+ if (isBlockItem(timelineItem)) {
+ return (
+
item.type === "block")
+ .map((item) => item.block)}
+ activeItem={selection}
+ block={timelineItem.block}
+ onActionClick={setActive}
+ onBlockItemClick={setActive}
+ />
+ );
+ }
+ if (isThoughtItem(timelineItem)) {
+ return (
+
+ );
+ }
+ })}
+
+
+
+
+
+ );
+}
+
+export { WorkflowRunOverview };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx
new file mode 100644
index 00000000..fd26404b
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx
@@ -0,0 +1,22 @@
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Skeleton } from "@/components/ui/skeleton";
+
+function WorkflowRunOverviewSkeleton() {
+ return (
+
+ );
+}
+
+export { WorkflowRunOverviewSkeleton };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx
new file mode 100644
index 00000000..574f548e
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx
@@ -0,0 +1,152 @@
+import { Status } from "@/api/types";
+import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
+import { ZoomableImage } from "@/components/ZoomableImage";
+import { useEffect, useState } from "react";
+import { statusIsNotFinalized } from "@/routes/tasks/types";
+import { useCredentialGetter } from "@/hooks/useCredentialGetter";
+import { useParams } from "react-router-dom";
+import { envCredential } from "@/util/env";
+import { toast } from "@/components/ui/use-toast";
+import { useQueryClient } from "@tanstack/react-query";
+
+type StreamMessage = {
+ task_id: string;
+ status: string;
+ screenshot?: string;
+};
+
+let socket: WebSocket | null = null;
+
+const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL;
+
+function WorkflowRunStream() {
+ const { data: workflowRun } = useWorkflowRunQuery();
+ const [streamImgSrc, setStreamImgSrc] = useState("");
+ const showStream = workflowRun && statusIsNotFinalized(workflowRun);
+ const credentialGetter = useCredentialGetter();
+ const { workflowRunId, workflowPermanentId } = useParams();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!showStream) {
+ return;
+ }
+
+ async function run() {
+ // Create WebSocket connection.
+ let credential = null;
+ if (credentialGetter) {
+ const token = await credentialGetter();
+ credential = `?token=Bearer ${token}`;
+ } else {
+ credential = `?apikey=${envCredential}`;
+ }
+ if (socket) {
+ socket.close();
+ }
+ socket = new WebSocket(
+ `${wssBaseUrl}/stream/workflow_runs/${workflowRunId}${credential}`,
+ );
+ // Listen for messages
+ socket.addEventListener("message", (event) => {
+ try {
+ const message: StreamMessage = JSON.parse(event.data);
+ if (message.screenshot) {
+ setStreamImgSrc(message.screenshot);
+ }
+ if (
+ message.status === "completed" ||
+ message.status === "failed" ||
+ message.status === "terminated"
+ ) {
+ socket?.close();
+ queryClient.invalidateQueries({
+ queryKey: ["workflowRuns"],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["workflowRun", workflowPermanentId, workflowRunId],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["workflowTasks", workflowRunId],
+ });
+ if (
+ message.status === "failed" ||
+ message.status === "terminated"
+ ) {
+ toast({
+ title: "Run Failed",
+ description: "The workflow run has failed.",
+ variant: "destructive",
+ });
+ } else if (message.status === "completed") {
+ toast({
+ title: "Run Completed",
+ description: "The workflow run has been completed.",
+ variant: "success",
+ });
+ }
+ }
+ } catch (e) {
+ console.error("Failed to parse message", e);
+ }
+ });
+
+ socket.addEventListener("close", () => {
+ socket = null;
+ });
+ }
+ run();
+
+ return () => {
+ if (socket) {
+ socket.close();
+ socket = null;
+ }
+ };
+ }, [
+ credentialGetter,
+ workflowRunId,
+ showStream,
+ queryClient,
+ workflowPermanentId,
+ ]);
+
+ if (workflowRun?.status === Status.Created) {
+ return (
+
+ Workflow has been created.
+ Stream will start when the workflow is running.
+
+ );
+ }
+ if (workflowRun?.status === Status.Queued) {
+ return (
+
+ Your workflow run is queued.
+ Stream will start when the workflow is running.
+
+ );
+ }
+
+ if (workflowRun?.status === Status.Running && streamImgSrc.length === 0) {
+ return (
+
+ Starting the stream...
+
+ );
+ }
+
+ if (workflowRun?.status === Status.Running && streamImgSrc.length > 0) {
+ return (
+
+
+
+ );
+ }
+ return null;
+}
+
+export { WorkflowRunStream };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx
new file mode 100644
index 00000000..70d5cc29
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx
@@ -0,0 +1,70 @@
+import { ActionsApiResponse } from "@/api/types";
+import {
+ isAction,
+ isWorkflowRunBlock,
+ WorkflowRunBlock,
+} from "../types/workflowRunTypes";
+import { ActionCard } from "./ActionCard";
+import { BlockCard } from "./BlockCard";
+import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
+
+type Props = {
+ activeItem: WorkflowRunOverviewActiveElement;
+ block: WorkflowRunBlock;
+ subBlocks: Array;
+ onBlockItemClick: (block: WorkflowRunBlock) => void;
+ onActionClick: (action: ActionsApiResponse) => void;
+};
+
+function WorkflowRunTimelineBlockItem({
+ activeItem,
+ block,
+ subBlocks,
+ onBlockItemClick,
+ onActionClick,
+}: Props) {
+ const actions = block.actions ? [...block.actions].reverse() : [];
+
+ return (
+
+ {actions.map((action, index) => {
+ return (
+
{
+ onActionClick(action);
+ }}
+ />
+ );
+ })}
+ {subBlocks.map((block) => {
+ return (
+
+ );
+ })}
+ {
+ onBlockItemClick(block);
+ }}
+ />
+
+ );
+}
+
+export { WorkflowRunTimelineBlockItem };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx
new file mode 100644
index 00000000..0bff9fcd
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx
@@ -0,0 +1,211 @@
+import { ActionsApiResponse, Status } from "@/api/types";
+import {
+ hasExtractedInformation,
+ isAction,
+ isObserverThought,
+ isWorkflowRunBlock,
+ ObserverThought,
+ WorkflowRunBlock,
+} from "../types/workflowRunTypes";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { CodeEditor } from "../components/CodeEditor";
+import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
+import { WorkflowBlockTypes } from "../types/workflowTypes";
+import { statusIsAFailureType } from "@/routes/tasks/types";
+import { SendEmailBlockInfo } from "./blockInfo/SendEmailBlockInfo";
+
+type Props = {
+ item:
+ | ActionsApiResponse
+ | ObserverThought
+ | WorkflowRunBlock
+ | "stream"
+ | null;
+};
+
+function WorkflowRunTimelineItemInfoSection({ item }: Props) {
+ if (!item) {
+ return null;
+ }
+ if (item === "stream") {
+ return null;
+ }
+ if (isAction(item)) {
+ return null;
+ }
+ if (isObserverThought(item)) {
+ return (
+
+
+
+ Observation
+ Thought
+ Answer
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ if (isWorkflowRunBlock(item)) {
+ if (
+ item.block_type === WorkflowBlockTypes.Task ||
+ item.block_type === WorkflowBlockTypes.Navigation ||
+ item.block_type === WorkflowBlockTypes.Action ||
+ item.block_type === WorkflowBlockTypes.Extraction ||
+ item.block_type === WorkflowBlockTypes.Validation ||
+ item.block_type === WorkflowBlockTypes.Login ||
+ item.block_type === WorkflowBlockTypes.FileDownload
+ ) {
+ return (
+
+
+
+ Navigation Goal
+ {item.status === Status.Completed && (
+
+ Extracted Information
+
+ )}
+ {item.status && statusIsAFailureType({ status: item.status }) && (
+ Failure Reason
+ )}
+ Parameters
+
+
+
+
+ {item.status === Status.Completed && (
+
+
+
+ )}
+ {item.status && statusIsAFailureType({ status: item.status }) && (
+
+
+
+ )}
+
+
+
+
+
+ );
+ }
+ if (item.block_type === WorkflowBlockTypes.SendEmail) {
+ if (
+ item.body !== null &&
+ typeof item.body !== "undefined" &&
+ item.recipients !== null &&
+ typeof item.recipients !== "undefined"
+ ) {
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ if (item.block_type === WorkflowBlockTypes.TextPrompt) {
+ if (item.prompt !== null) {
+ return (
+
+
+
+ Prompt
+ Output
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return null;
+ }
+
+ if (item.block_type === WorkflowBlockTypes.Wait) {
+ if (item.wait_sec !== null && typeof item.wait_sec !== "undefined") {
+ return (
+
+ Wait Time
+ {item.wait_sec} Seconds
+
+ );
+ }
+ return null;
+ }
+
+ return (
+
+
+
+ Output
+
+
+
+
+
+
+ );
+ }
+}
+
+export { WorkflowRunTimelineItemInfoSection };
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx
new file mode 100644
index 00000000..93f3a460
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx
@@ -0,0 +1,29 @@
+type Props = {
+ recipients: Array;
+ body: string;
+};
+
+function SendEmailBlockInfo({ recipients, body }: Props) {
+ return (
+
+
+
+ From
+ hello@skyvern.com
+
+
+ To
+ {recipients.map((recipient) => {
+ return {recipient};
+ })}
+
+
+
+
+ );
+}
+
+export { SendEmailBlockInfo };
diff --git a/skyvern-frontend/src/util/env.ts b/skyvern-frontend/src/util/env.ts
index 19721eb6..d54adc00 100644
--- a/skyvern-frontend/src/util/env.ts
+++ b/skyvern-frontend/src/util/env.ts
@@ -19,13 +19,4 @@ if (!artifactApiBaseUrl) {
console.warn("artifactApiBaseUrl environment variable was not set");
}
-const observerEnabled = import.meta.env.VITE_OBSERVER_ENABLED as string;
-const observerFeatureEnabled = observerEnabled === "true";
-
-export {
- apiBaseUrl,
- environment,
- envCredential,
- artifactApiBaseUrl,
- observerFeatureEnabled,
-};
+export { apiBaseUrl, environment, envCredential, artifactApiBaseUrl };