diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index c67bb49d..c25259d8 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -213,6 +213,7 @@ export type WorkflowRunStatusApiResponse = { downloaded_file_urls: Array | null; total_steps: number | null; total_cost: number | null; + observer_cruise: ObserverCruise | null; }; export type TaskGenerationApiResponse = { diff --git a/skyvern-frontend/src/components/SwitchBarNavigation.tsx b/skyvern-frontend/src/components/SwitchBarNavigation.tsx index c955b17d..c7943184 100644 --- a/skyvern-frontend/src/components/SwitchBarNavigation.tsx +++ b/skyvern-frontend/src/components/SwitchBarNavigation.tsx @@ -1,5 +1,5 @@ import { cn } from "@/util/utils"; -import { NavLink } from "react-router-dom"; +import { NavLink, useSearchParams } from "react-router-dom"; type Option = { label: string; @@ -11,12 +11,14 @@ type Props = { }; function SwitchBarNavigation({ options }: Props) { + const [searchParams] = useSearchParams(); + return (
{options.map((option) => { return ( { diff --git a/skyvern-frontend/src/components/icons/BrainIcon.tsx b/skyvern-frontend/src/components/icons/BrainIcon.tsx new file mode 100644 index 00000000..0acd5167 --- /dev/null +++ b/skyvern-frontend/src/components/icons/BrainIcon.tsx @@ -0,0 +1,26 @@ +type Props = { + className?: string; +}; + +function BrainIcon({ className }: Props) { + return ( + + + + ); +} + +export { BrainIcon }; diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 19ec77f4..faaeb36c 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -27,12 +27,17 @@ import { } from "@radix-ui/react-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import fetchToCurl from "fetch-to-curl"; -import { Link, Outlet, useParams } from "react-router-dom"; +import { Link, Outlet, useParams, useSearchParams } from "react-router-dom"; import { statusIsFinalized, statusIsRunningOrQueued } from "../tasks/types"; import { useWorkflowQuery } from "./hooks/useWorkflowQuery"; import { useWorkflowRunQuery } from "./hooks/useWorkflowRunQuery"; +import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline"; +import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery"; +import { findActiveItem } from "./workflowRun/workflowTimelineUtils"; function WorkflowRun() { + const [searchParams, setSearchParams] = useSearchParams(); + const active = searchParams.get("active"); const { workflowRunId, workflowPermanentId } = useParams(); const credentialGetter = useCredentialGetter(); const apiCredential = useApiCredential(); @@ -45,6 +50,8 @@ function WorkflowRun() { const { data: workflowRun, isLoading: workflowRunIsLoading } = useWorkflowRunQuery(); + const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery(); + const cancelWorkflowMutation = useMutation({ mutationFn: async () => { const client = await getClient(credentialGetter); @@ -78,7 +85,11 @@ function WorkflowRun() { workflowRun && statusIsRunningOrQueued(workflowRun); const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun); - + const selection = findActiveItem( + workflowRunTimeline ?? [], + active, + !!workflowRunIsFinalized, + ); const parameters = workflowRun?.parameters ?? {}; const proxyLocation = workflowRun?.proxy_location ?? ProxyLocation.Residential; @@ -108,6 +119,13 @@ function WorkflowRun() {
) : null; + function handleSetActiveItem(id: string) { + searchParams.set("active", id); + setSearchParams(searchParams, { + replace: true, + }); + } + return (
@@ -230,7 +248,28 @@ function WorkflowRun() { }, ]} /> - +
+
+ +
+
+ { + handleSetActiveItem(item.action.action_id); + }} + onBlockItemSelected={(item) => { + handleSetActiveItem(item.workflow_run_block_id); + }} + onLiveStreamSelected={() => { + handleSetActiveItem("stream"); + }} + onObserverThoughtCardSelected={(item) => { + handleSetActiveItem(item.observer_thought_id); + }} + /> +
+
); } diff --git a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts index 3cdb40ed..650955b9 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts @@ -48,6 +48,13 @@ export type WorkflowRunBlock = { wait_sec?: number | null; created_at: string; modified_at: string; + + // for loop block itself + loop_values: Array | null; + + // for blocks in loop + current_value: string | null; + current_index: number | null; }; export type WorkflowRunTimelineBlockItem = { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx index 17d42478..86202f88 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx @@ -3,21 +3,33 @@ 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"; +import { useCallback } from "react"; type Props = { action: ActionsApiResponse; index: number; active: boolean; - onClick: () => void; + onClick: React.DOMAttributes["onClick"]; }; function ActionCard({ action, onClick, active, index }: Props) { const success = action.status === Status.Completed; + const refCallback = useCallback((element: HTMLDivElement | null) => { + if (element && active) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + // this should only run once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/TaskBlockParameters.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/TaskBlockParameters.tsx new file mode 100644 index 00000000..eef2a6af --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/TaskBlockParameters.tsx @@ -0,0 +1,136 @@ +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { Input } from "@/components/ui/input"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { WorkflowRunBlock } from "../types/workflowRunTypes"; +import { isTaskVariantBlock, WorkflowBlockTypes } from "../types/workflowTypes"; + +type Props = { + block: WorkflowRunBlock; +}; + +function TaskBlockParameters({ block }: Props) { + const isTaskVariant = isTaskVariantBlock(block); + if (!isTaskVariant) { + return null; + } + + const showNavigationParameters = + block.block_type === WorkflowBlockTypes.Task || + block.block_type === WorkflowBlockTypes.Action || + block.block_type === WorkflowBlockTypes.Login || + block.block_type === WorkflowBlockTypes.Navigation; + + const showDataExtractionParameters = + block.block_type === WorkflowBlockTypes.Task || + block.block_type === WorkflowBlockTypes.Extraction; + + const showValidationParameters = + block.block_type === WorkflowBlockTypes.Validation; + + return ( + <> +
+
+

URL

+

+ The starting URL for the block +

+
+ +
+ + {showNavigationParameters ? ( +
+
+

Navigation Goal

+

+ Where should Skyvern go and what should Skyvern do? +

+
+ +
+ ) : null} + + {showNavigationParameters ? ( +
+
+

Navigation Payload

+

+ Specify important parameters, routes, or states +

+
+ +
+ ) : null} + + {showDataExtractionParameters ? ( +
+
+

Data Extraction Goal

+

+ What outputs are you looking to get? +

+
+ +
+ ) : null} + + {showDataExtractionParameters ? ( +
+
+

Data Schema

+

+ Specify the output format in JSON +

+
+ +
+ ) : null} + + {showValidationParameters ? ( +
+
+

Completion Criteria

+

Complete if...

+
+ +
+ ) : null} + + {showValidationParameters ? ( +
+
+

Termination Criteria

+

Terminate if...

+
+ +
+ ) : null} + + ); +} + +export { TaskBlockParameters }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx index 191898bc..10d3c538 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx @@ -1,6 +1,8 @@ -import { PersonIcon } from "@radix-ui/react-icons"; +import { QuestionMarkIcon } from "@radix-ui/react-icons"; import { ObserverThought } from "../types/workflowRunTypes"; import { cn } from "@/util/utils"; +import { BrainIcon } from "@/components/icons/BrainIcon"; +import { useCallback } from "react"; type Props = { active: boolean; @@ -9,6 +11,17 @@ type Props = { }; function ThoughtCard({ thought, onClick, active }: Props) { + const refCallback = useCallback((element: HTMLDivElement | null) => { + if (element && active) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + // this should only run once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
{ onClick(thought); }} + ref={refCallback} >
- Thought -
- +
+ + Thinking +
+
+ Decision
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx index 2843d276..cc851a4a 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx @@ -1,47 +1,165 @@ -import { Label } from "@/components/ui/label"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { CodeEditor } from "../components/CodeEditor"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { useActiveWorkflowRunItem } from "./useActiveWorkflowRunItem"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { isAction, isWorkflowRunBlock } from "../types/workflowRunTypes"; +import { findBlockSurroundingAction } from "./workflowTimelineUtils"; +import { TaskBlockParameters } from "./TaskBlockParameters"; +import { isTaskVariantBlock, WorkflowBlockTypes } from "../types/workflowTypes"; +import { Input } from "@/components/ui/input"; +import { ProxySelector } from "@/components/ProxySelector"; +import { SendEmailBlockParameters } from "./blockInfo/SendEmailBlockInfo"; +import { ProxyLocation } from "@/api/types"; function WorkflowPostRunParameters() { + const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } = + useWorkflowRunTimelineQuery(); + const [activeItem] = useActiveWorkflowRunItem(); const { data: workflowRun, isLoading: workflowRunIsLoading } = useWorkflowRunQuery(); const parameters = workflowRun?.parameters ?? {}; - if (workflowRunIsLoading) { + if (workflowRunIsLoading || workflowRunTimelineIsLoading) { return
Loading workflow parameters...
; } - return Object.entries(parameters).length > 0 ? ( -
-
-

Input Parameter Values

-
- {Object.entries(parameters).map(([key, value]) => { - return ( -
- - {typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" ? ( - - ) : ( - - )} + if (!workflowRun || !workflowRunTimeline) { + return null; + } + + function getActiveBlock() { + if (!workflowRunTimeline) { + return; + } + if (isWorkflowRunBlock(activeItem)) { + return activeItem; + } + if (isAction(activeItem)) { + return findBlockSurroundingAction( + workflowRunTimeline, + activeItem.action_id, + ); + } + } + + const activeBlock = getActiveBlock(); + + return ( +
+ {activeBlock && isTaskVariantBlock(activeBlock) ? ( +
+
+

Block Parameters

+
- ); - })} +
+ ) : null} + {activeBlock && + activeBlock.block_type === WorkflowBlockTypes.SendEmail ? ( +
+
+

Block Parameters

+ +
+
+ ) : null} + {activeBlock && activeBlock.block_type === WorkflowBlockTypes.ForLoop ? ( +
+
+

Block Parameters

+
+
+

Loop Values

+

+ The values that are being looped over +

+
+ +
+
+
+ ) : null} +
+
+

Workflow Input Parameters

+ {Object.entries(parameters).map(([key, value]) => { + return ( +
+
+

{key}

+
+ {typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" ? ( + + ) : ( + + )} +
+ ); + })} + {Object.entries(parameters).length === 0 ? ( +
No input parameters found for this workflow
+ ) : null} +

Other Workflow Parameters

+
+
+

Webhook Callback URL

+
+ +
+
+
+

Proxy Location

+
+ { + // TODO + }} + /> +
+
+
+ {workflowRun.observer_cruise ? ( +
+
+

Observer Parameters

+
+
+

Observer Prompt

+

+ The original prompt for the observer +

+
+ +
+
+
+ ) : null}
- ) : ( - Object.entries(parameters).length === 0 && ( -
This workflow doesn't have any input parameters.
- ) ); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx index 2ebc41dc..da6b438d 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx @@ -1,44 +1,128 @@ import { FileIcon } from "@radix-ui/react-icons"; import { CodeEditor } from "../components/CodeEditor"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { useActiveWorkflowRunItem } from "./useActiveWorkflowRunItem"; +import { + hasExtractedInformation, + isAction, + isWorkflowRunBlock, +} from "../types/workflowRunTypes"; +import { findBlockSurroundingAction } from "./workflowTimelineUtils"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { Status } from "@/api/types"; +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; function WorkflowRunOutput() { + const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } = + useWorkflowRunTimelineQuery(); + const [activeItem] = useActiveWorkflowRunItem(); const { data: workflowRun } = useWorkflowRunQuery(); + + if (workflowRunTimelineIsLoading) { + return
Loading...
; + } + + if (!workflowRunTimeline) { + return null; + } + + function getActiveBlock() { + if (!workflowRunTimeline) { + return; + } + if (isWorkflowRunBlock(activeItem)) { + return activeItem; + } + if (isAction(activeItem)) { + return findBlockSurroundingAction( + workflowRunTimeline, + activeItem.action_id, + ); + } + } + + const activeBlock = getActiveBlock(); + + const showExtractedInformation = + activeBlock && activeBlock.status === Status.Completed; + const outputs = workflowRun?.outputs; const fileUrls = workflowRun?.downloaded_file_urls ?? []; return ( -
-
-

Workflow Run Output

-
- -
-
-

Downloaded Files

-
-
- {fileUrls.length > 0 ? ( - fileUrls.map((url, index) => { - return ( - - ); - }) - ) : ( -
No files downloaded
- )} +
+ {activeBlock ? ( +
+
+

Block Outputs

+
+

+ {showExtractedInformation + ? "Extracted Information" + : "Failure Reason"} +

+ {showExtractedInformation ? ( + + ) : ( + + )} +
+
+
+ ) : null} +
+
+

Workflow Run Outputs

+ +
+
+
+
+

Workflow Run Downloaded Files

+
+ {fileUrls.length > 0 ? ( + fileUrls.map((url, index) => { + return ( + + ); + }) + ) : ( +
No files downloaded
+ )} +
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx index 36923f8f..9586d022 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx @@ -1,35 +1,22 @@ +import { ActionsApiResponse } from "@/api/types"; 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 { statusIsFinalized } from "@/routes/tasks/types"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; import { - isActionItem, - isBlockItem, + isAction, isObserverThought, - isTaskVariantBlockItem, - 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"; import { WorkflowRunBlockScreenshot } from "./WorkflowRunBlockScreenshot"; - -const formatter = Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}); +import { WorkflowRunStream } from "./WorkflowRunStream"; +import { useSearchParams } from "react-router-dom"; +import { findActiveItem } from "./workflowTimelineUtils"; +import { Skeleton } from "@/components/ui/skeleton"; export type ActionItem = { block: WorkflowRunBlock; @@ -37,14 +24,15 @@ export type ActionItem = { }; export type WorkflowRunOverviewActiveElement = - | ActionItem + | ActionsApiResponse | ObserverThought | WorkflowRunBlock | "stream" | null; function WorkflowRunOverview() { - const [active, setActive] = useState(null); + const [searchParams] = useSearchParams(); + const active = searchParams.get("active"); const { data: workflowRun, isLoading: workflowRunIsLoading } = useWorkflowRunQuery(); @@ -52,7 +40,11 @@ function WorkflowRunOverview() { useWorkflowRunTimelineQuery(); if (workflowRunIsLoading || workflowRunTimelineIsLoading) { - return ; + return ( + + + + ); } if (!workflowRun) { @@ -63,141 +55,33 @@ function WorkflowRunOverview() { 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 - ) { - const last = timelineItem.block.actions.length - 1; - const actionItem: ActionItem = { - block: timelineItem.block, - action: timelineItem.block.actions[last]!, - }; - return actionItem; - } - return timelineItem.block; - } - if (isThoughtItem(timelineItem)) { - return timelineItem.thought; - } - } - } - return active; - } - - const selection = getActiveSelection(); - - const numberOfActions = workflowRunTimeline.reduce((total, current) => { - if (isTaskVariantBlockItem(current)) { - return total + current.block!.actions!.length; - } - return total + 0; - }, 0); + const workflowRunIsFinalized = statusIsFinalized(workflowRun); + const selection = findActiveItem( + workflowRunTimeline, + active, + workflowRunIsFinalized, + ); return ( -
-
- - {selection === "stream" && } - {selection !== "stream" && isActionItem(selection) && ( - - )} - {isWorkflowRunBlock(selection) && ( - - )} - {isObserverThought(selection) && ( - - )} - - - -
-
-
-
- Actions: {numberOfActions} -
-
- Steps: {workflowRun.total_steps ?? 0} -
-
- Cost: {formatter.format(workflowRun.total_cost ?? 0)} -
-
- - -
- {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 ( - - ); - } - })} -
-
-
-
-
+ + {selection === "stream" && } + {selection !== "stream" && isAction(selection) && ( + + )} + {isWorkflowRunBlock(selection) && ( + + )} + {isObserverThought(selection) && ( + + )} + ); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx new file mode 100644 index 00000000..d2cdad89 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx @@ -0,0 +1,139 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { + isBlockItem, + isObserverThought, + isTaskVariantBlockItem, + isThoughtItem, + ObserverThought, + WorkflowRunBlock, +} from "../types/workflowRunTypes"; +import { + ActionItem, + WorkflowRunOverviewActiveElement, +} from "./WorkflowRunOverview"; +import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; +import { statusIsNotFinalized } from "@/routes/tasks/types"; +import { cn } from "@/util/utils"; +import { ThoughtCard } from "./ThoughtCard"; +import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem"; +import { DotFilledIcon } from "@radix-ui/react-icons"; + +const formatter = Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +type Props = { + activeItem: WorkflowRunOverviewActiveElement; + onLiveStreamSelected: () => void; + onObserverThoughtCardSelected: (item: ObserverThought) => void; + onActionItemSelected: (item: ActionItem) => void; + onBlockItemSelected: (item: WorkflowRunBlock) => void; +}; + +function WorkflowRunTimeline({ + activeItem, + onLiveStreamSelected, + onObserverThoughtCardSelected, + onActionItemSelected, + onBlockItemSelected, +}: Props) { + const { data: workflowRun, isLoading: workflowRunIsLoading } = + useWorkflowRunQuery(); + + const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } = + useWorkflowRunTimelineQuery(); + + if (workflowRunIsLoading || workflowRunTimelineIsLoading) { + return ; + } + + if (!workflowRun || !workflowRunTimeline) { + return null; + } + + const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun); + + const timeline = workflowRunTimeline.slice().reverse(); + + const numberOfActions = workflowRunTimeline.reduce((total, current) => { + if (isTaskVariantBlockItem(current)) { + return total + current.block!.actions!.length; + } + return total + 0; + }, 0); + + return ( +
+
+
+ Actions: {numberOfActions} +
+
+ Steps: {workflowRun.total_steps ?? 0} +
+
+ Cost: {formatter.format(workflowRun.total_cost ?? 0)} +
+
+ + +
+ {workflowRunIsNotFinalized && ( +
+
+ + Live +
+
+ )} + {timeline.length === 0 &&
Workflow timeline is empty
} + {timeline?.map((timelineItem) => { + if (isBlockItem(timelineItem)) { + return ( + item.type === "block") + .map((item) => item.block)} + activeItem={activeItem} + block={timelineItem.block} + onActionClick={onActionItemSelected} + onBlockItemClick={onBlockItemSelected} + /> + ); + } + if (isThoughtItem(timelineItem)) { + return ( + + ); + } + })} +
+
+
+
+ ); +} + +export { WorkflowRunTimeline }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index ac7a6537..07439ef7 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -1,14 +1,20 @@ +import { CubeIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; +import { workflowBlockTitle } from "../editor/nodes/types"; +import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; import { - isActionItem, + isAction, isWorkflowRunBlock, WorkflowRunBlock, } from "../types/workflowRunTypes"; import { ActionCard } from "./ActionCard"; -import { BlockCard } from "./BlockCard"; import { ActionItem, WorkflowRunOverviewActiveElement, } from "./WorkflowRunOverview"; +import { cn } from "@/util/utils"; +import { isTaskVariantBlock } from "../types/workflowTypes"; +import { Link } from "react-router-dom"; +import { useCallback } from "react"; type Props = { activeItem: WorkflowRunOverviewActiveElement; @@ -27,19 +33,86 @@ function WorkflowRunTimelineBlockItem({ }: Props) { const actions = block.actions ? [...block.actions].reverse() : []; + const hasActiveAction = + isAction(activeItem) && + Boolean( + block.actions?.find( + (action) => action.action_id === activeItem.action_id, + ), + ); + const isActiveBlock = + isWorkflowRunBlock(activeItem) && + activeItem.workflow_run_block_id === block.workflow_run_block_id; + + const showDiagnosticLink = + isTaskVariantBlock(block) && (hasActiveAction || isActiveBlock); + + const refCallback = useCallback((element: HTMLDivElement | null) => { + if ( + element && + isWorkflowRunBlock(activeItem) && + activeItem.workflow_run_block_id === block.workflow_run_block_id + ) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + // this should only run once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( -
+
{ + event.stopPropagation(); + onBlockItemClick(block); + }} + ref={refCallback} + > +
+
+ + {workflowBlockTitle[block.block_type]} +
+
+ {showDiagnosticLink ? ( + +
+ + Diagnostics +
+ + ) : ( + <> + + Block + + )} +
+
{actions.map((action, index) => { return ( { + onClick={(event) => { + event.stopPropagation(); const actionItem: ActionItem = { block, action, @@ -61,16 +134,6 @@ function WorkflowRunTimelineBlockItem({ /> ); })} - { - onBlockItemClick(block); - }} - />
); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx index 86ef35b8..916aad74 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx @@ -11,10 +11,10 @@ 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"; import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; import { ExternalLinkIcon } from "@radix-ui/react-icons"; import { Link } from "react-router-dom"; +import { SendEmailBlockParameters } from "./blockInfo/SendEmailBlockInfo"; type Props = { activeItem: WorkflowRunOverviewActiveElement; @@ -151,10 +151,16 @@ function WorkflowRunTimelineItemInfoSection({ activeItem }: Props) { item.body !== null && typeof item.body !== "undefined" && item.recipients !== null && - typeof item.recipients !== "undefined" + typeof item.recipients !== "undefined" && + item.subject !== null && + typeof item.subject !== "undefined" ) { return ( - + ); } return null; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx index 93f3a460..683d0aa4 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx @@ -1,29 +1,35 @@ +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { Input } from "@/components/ui/input"; + type Props = { recipients: Array; body: string; + subject: string; }; -function SendEmailBlockInfo({ recipients, body }: Props) { +function SendEmailBlockParameters({ recipients, body, subject }: Props) { return ( -
-
-
- From - hello@skyvern.com -
-
- To - {recipients.map((recipient) => { - return {recipient}; - })} +
+
+
+

To

+
-
- Body -

{body}

+
+
+

Subject

+
+ +
+
+
+

Body

+
+
); } -export { SendEmailBlockInfo }; +export { SendEmailBlockParameters }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts b/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts new file mode 100644 index 00000000..c7813960 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts @@ -0,0 +1,36 @@ +import { useSearchParams } from "react-router-dom"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { statusIsFinalized } from "@/routes/tasks/types"; +import { findActiveItem } from "./workflowTimelineUtils"; +import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; + +function useActiveWorkflowRunItem(): [ + WorkflowRunOverviewActiveElement, + (item: string) => void, +] { + const [searchParams, setSearchParams] = useSearchParams(); + const active = searchParams.get("active"); + + const { data: workflowRun } = useWorkflowRunQuery(); + + const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery(); + + const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun); + const activeItem = findActiveItem( + workflowRunTimeline ?? [], + active, + !!workflowRunIsFinalized, + ); + + function handleSetActiveItem(id: string) { + searchParams.set("active", id); + setSearchParams(searchParams, { + replace: true, + }); + } + + return [activeItem, handleSetActiveItem]; +} + +export { useActiveWorkflowRunItem }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts new file mode 100644 index 00000000..964a9c50 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts @@ -0,0 +1,93 @@ +import { + isBlockItem, + isThoughtItem, + WorkflowRunBlock, + WorkflowRunTimelineItem, +} from "../types/workflowRunTypes"; +import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; + +function findBlockSurroundingAction( + timeline: Array, + actionId: string, +): WorkflowRunBlock | undefined { + const stack = [...timeline]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === "block") { + const action = current.block.actions?.find( + (action) => action.action_id === actionId, + ); + if (action) { + return current.block; + } + } + if (current.children) { + stack.push(...current.children); + } + } +} + +function findActiveItem( + timeline: Array, + target: string | null, + workflowRunIsFinalized: boolean, +): WorkflowRunOverviewActiveElement { + if (target === null) { + if (!workflowRunIsFinalized) { + return "stream"; + } + if (timeline?.length > 0) { + const last = timeline.length - 1; + const timelineItem = timeline![last]; + if (isBlockItem(timelineItem)) { + if ( + timelineItem.block.actions && + timelineItem.block.actions.length > 0 + ) { + const last = timelineItem.block.actions.length - 1; + return timelineItem.block.actions[last]!; + } + return timelineItem.block; + } + if (isThoughtItem(timelineItem)) { + return timelineItem.thought; + } + } + } + if (target === "stream") { + return "stream"; + } + const stack = [...timeline]; + while (stack.length > 0) { + const current = stack.pop()!; + if ( + current.type === "block" && + current.block.workflow_run_block_id === target + ) { + return current.block; + } + if ( + current.type === "thought" && + current.thought.observer_thought_id === target + ) { + return current.thought; + } + if (current.type === "block") { + const actions = current.block.actions; + if (actions) { + const activeAction = actions.find( + (action) => action.action_id === target, + ); + if (activeAction) { + return activeAction; + } + } + } + if (current.children) { + stack.push(...current.children); + } + } + return null; +} + +export { findActiveItem, findBlockSurroundingAction };