Observer Timeline UI Updates (#1480)

This commit is contained in:
Shuchang Zheng
2025-01-03 13:42:01 -08:00
committed by GitHub
parent 66b35b70fb
commit d05b39f0fc
17 changed files with 936 additions and 266 deletions

View File

@@ -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() {
</div>
) : null;
function handleSetActiveItem(id: string) {
searchParams.set("active", id);
setSearchParams(searchParams, {
replace: true,
});
}
return (
<div className="space-y-8">
<header className="flex justify-between">
@@ -230,7 +248,28 @@ function WorkflowRun() {
},
]}
/>
<Outlet />
<div className="flex h-[42rem] gap-6">
<div className="w-2/3">
<Outlet />
</div>
<div className="w-1/3">
<WorkflowRunTimeline
activeItem={selection}
onActionItemSelected={(item) => {
handleSetActiveItem(item.action.action_id);
}}
onBlockItemSelected={(item) => {
handleSetActiveItem(item.workflow_run_block_id);
}}
onLiveStreamSelected={() => {
handleSetActiveItem("stream");
}}
onObserverThoughtCardSelected={(item) => {
handleSetActiveItem(item.observer_thought_id);
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -48,6 +48,13 @@ export type WorkflowRunBlock = {
wait_sec?: number | null;
created_at: string;
modified_at: string;
// for loop block itself
loop_values: Array<unknown> | null;
// for blocks in loop
current_value: string | null;
current_index: number | null;
};
export type WorkflowRunTimelineBlockItem = {

View File

@@ -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<HTMLDivElement>["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 (
<div
className={cn(
"flex cursor-pointer rounded-lg border-2 bg-slate-elevation3 hover:border-slate-50",
"flex cursor-pointer rounded-lg border-2 border-transparent bg-slate-elevation3 hover:border-slate-50",
{
"border-l-destructive": !success,
"border-l-success": success,
@@ -25,6 +37,7 @@ function ActionCard({ action, onClick, active, index }: Props) {
},
)}
onClick={onClick}
ref={refCallback}
>
<div className="flex-1 space-y-2 p-4 pl-5">
<div className="flex justify-between">

View File

@@ -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 (
<>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">URL</h1>
<h2 className="text-base text-slate-400">
The starting URL for the block
</h2>
</div>
<Input value={block.url ?? ""} readOnly />
</div>
{showNavigationParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Navigation Goal</h1>
<h2 className="text-base text-slate-400">
Where should Skyvern go and what should Skyvern do?
</h2>
</div>
<AutoResizingTextarea value={block.navigation_goal ?? ""} readOnly />
</div>
) : null}
{showNavigationParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Navigation Payload</h1>
<h2 className="text-base text-slate-400">
Specify important parameters, routes, or states
</h2>
</div>
<CodeEditor
className="w-full"
language="json"
value={JSON.stringify(block.navigation_payload, null, 2)}
readOnly
minHeight="96px"
maxHeight="200px"
/>
</div>
) : null}
{showDataExtractionParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Data Extraction Goal</h1>
<h2 className="text-base text-slate-400">
What outputs are you looking to get?
</h2>
</div>
<AutoResizingTextarea
value={block.data_extraction_goal ?? ""}
readOnly
/>
</div>
) : null}
{showDataExtractionParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Data Schema</h1>
<h2 className="text-base text-slate-400">
Specify the output format in JSON
</h2>
</div>
<CodeEditor
className="w-full"
language="json"
value={JSON.stringify(block.data_schema, null, 2)}
readOnly
minHeight="96px"
maxHeight="200px"
/>
</div>
) : null}
{showValidationParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Completion Criteria</h1>
<h2 className="text-base text-slate-400">Complete if...</h2>
</div>
<AutoResizingTextarea
value={block.complete_criterion ?? ""}
readOnly
/>
</div>
) : null}
{showValidationParameters ? (
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Termination Criteria</h1>
<h2 className="text-base text-slate-400">Terminate if...</h2>
</div>
<AutoResizingTextarea
value={block.terminate_criterion ?? ""}
readOnly
/>
</div>
) : null}
</>
);
}
export { TaskBlockParameters };

View File

@@ -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 (
<div
className={cn(
@@ -20,11 +33,15 @@ function ThoughtCard({ thought, onClick, active }: Props) {
onClick={() => {
onClick(thought);
}}
ref={refCallback}
>
<div className="flex justify-between">
<span>Thought</span>
<div className="flex items-center gap-1 bg-slate-elevation5">
<PersonIcon className="size-4" />
<div className="flex gap-3">
<BrainIcon className="size-6" />
<span>Thinking</span>
</div>
<div className="flex items-center gap-1 rounded-sm bg-slate-elevation5 px-2 py-1">
<QuestionMarkIcon className="size-4" />
<span className="text-xs">Decision</span>
</div>
</div>

View File

@@ -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 <div>Loading workflow parameters...</div>;
}
return Object.entries(parameters).length > 0 ? (
<div className="space-y-4 rounded-lg bg-slate-elevation3 px-6 py-5">
<header>
<h2 className="text-lg font-semibold">Input Parameter Values</h2>
</header>
{Object.entries(parameters).map(([key, value]) => {
return (
<div key={key} className="space-y-2">
<Label className="text-lg">{key}</Label>
{typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ? (
<AutoResizingTextarea value={String(value)} readOnly />
) : (
<CodeEditor
value={JSON.stringify(value, null, 2)}
readOnly
language="json"
minHeight="96px"
maxHeight="500px"
/>
)}
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 (
<div className="space-y-5">
{activeBlock && isTaskVariantBlock(activeBlock) ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Block Parameters</h1>
<TaskBlockParameters block={activeBlock} />
</div>
);
})}
</div>
) : null}
{activeBlock &&
activeBlock.block_type === WorkflowBlockTypes.SendEmail ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Block Parameters</h1>
<SendEmailBlockParameters
body={activeBlock.body ?? ""}
recipients={activeBlock.recipients ?? []}
subject={activeBlock.subject ?? ""}
/>
</div>
</div>
) : null}
{activeBlock && activeBlock.block_type === WorkflowBlockTypes.ForLoop ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Block Parameters</h1>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Loop Values</h1>
<h2 className="text-base text-slate-400">
The values that are being looped over
</h2>
</div>
<CodeEditor
className="w-full"
language="json"
value={JSON.stringify(activeBlock.loop_values, null, 2)}
readOnly
minHeight="96px"
maxHeight="200px"
/>
</div>
</div>
</div>
) : null}
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Workflow Input Parameters</h1>
{Object.entries(parameters).map(([key, value]) => {
return (
<div key={key} className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">{key}</h1>
</div>
{typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ? (
<AutoResizingTextarea value={String(value)} readOnly />
) : (
<CodeEditor
value={JSON.stringify(value, null, 2)}
readOnly
language="json"
minHeight="96px"
maxHeight="200px"
className="w-full"
/>
)}
</div>
);
})}
{Object.entries(parameters).length === 0 ? (
<div>No input parameters found for this workflow</div>
) : null}
<h1 className="text-lg font-bold">Other Workflow Parameters</h1>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Webhook Callback URL</h1>
</div>
<Input value={workflowRun.webhook_callback_url ?? ""} readOnly />
</div>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Proxy Location</h1>
</div>
<ProxySelector
value={workflowRun.proxy_location ?? ProxyLocation.Residential}
onChange={() => {
// TODO
}}
/>
</div>
</div>
</div>
{workflowRun.observer_cruise ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Observer Parameters</h1>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Observer Prompt</h1>
<h2 className="text-base text-slate-400">
The original prompt for the observer
</h2>
</div>
<Input
value={workflowRun.observer_cruise.prompt ?? ""}
readOnly
/>
</div>
</div>
</div>
) : null}
</div>
) : (
Object.entries(parameters).length === 0 && (
<div>This workflow doesn't have any input parameters.</div>
)
);
}

View File

@@ -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 <div>Loading...</div>;
}
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 (
<div className="space-y-4">
<header>
<h2 className="text-lg font-semibold">Workflow Run Output</h2>
</header>
<CodeEditor
language="json"
value={
outputs ? JSON.stringify(outputs, null, 2) : "Waiting for outputs.."
}
readOnly
minHeight="96px"
maxHeight="500px"
/>
<div className="space-y-4">
<header>
<h2 className="text-lg font-semibold">Downloaded Files</h2>
</header>
<div className="space-y-2">
{fileUrls.length > 0 ? (
fileUrls.map((url, index) => {
return (
<div key={url} title={url} className="flex gap-2">
<FileIcon className="size-6" />
<a href={url} className="underline underline-offset-4">
<span>{`File ${index + 1}`}</span>
</a>
</div>
);
})
) : (
<div>No files downloaded</div>
)}
<div className="space-y-5">
{activeBlock ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Block Outputs</h1>
<div className="space-y-2">
<h2>
{showExtractedInformation
? "Extracted Information"
: "Failure Reason"}
</h2>
{showExtractedInformation ? (
<CodeEditor
language="json"
value={JSON.stringify(
(hasExtractedInformation(activeBlock.output) &&
activeBlock.output.extracted_information) ??
null,
null,
2,
)}
minHeight="96px"
maxHeight="200px"
readOnly
/>
) : (
<AutoResizingTextarea
value={
activeBlock.status === "canceled"
? "This block was cancelled"
: activeBlock.failure_reason ?? ""
}
readOnly
/>
)}
</div>
</div>
</div>
) : null}
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Workflow Run Outputs</h1>
<CodeEditor
language="json"
value={
outputs
? JSON.stringify(outputs, null, 2)
: "Waiting for outputs.."
}
readOnly
minHeight="96px"
maxHeight="200px"
/>
</div>
</div>
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4">
<h1 className="text-lg font-bold">Workflow Run Downloaded Files</h1>
<div className="space-y-2">
{fileUrls.length > 0 ? (
fileUrls.map((url, index) => {
return (
<div key={url} title={url} className="flex gap-2">
<FileIcon className="size-6" />
<a href={url} className="underline underline-offset-4">
<span>{`File ${index + 1}`}</span>
</a>
</div>
);
})
) : (
<div>No files downloaded</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -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<WorkflowRunOverviewActiveElement>(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 <WorkflowRunOverviewSkeleton />;
return (
<AspectRatio ratio={16 / 9}>
<Skeleton className="h-full w-full" />
</AspectRatio>
);
}
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 (
<div className="flex h-[42rem] gap-6">
<div className="w-2/3 space-y-4">
<AspectRatio ratio={16 / 9} className="overflow-y-hidden">
{selection === "stream" && <WorkflowRunStream />}
{selection !== "stream" && isActionItem(selection) && (
<ActionScreenshot
index={selection.action.action_order ?? 0}
stepId={selection.action.step_id ?? ""}
/>
)}
{isWorkflowRunBlock(selection) && (
<WorkflowRunBlockScreenshot
workflowRunBlockId={selection.workflow_run_block_id}
/>
)}
{isObserverThought(selection) && (
<ObserverThoughtScreenshot
observerThoughtId={selection.observer_thought_id}
/>
)}
</AspectRatio>
<WorkflowRunTimelineItemInfoSection activeItem={selection} />
</div>
<div className="w-1/3 min-w-0 space-y-4 rounded bg-slate-elevation1 p-4">
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Actions: {numberOfActions}
</div>
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Steps: {workflowRun.total_steps ?? 0}
</div>
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Cost: {formatter.format(workflowRun.total_cost ?? 0)}
</div>
</div>
<ScrollArea>
<ScrollAreaViewport className="max-h-[37rem]">
<div className="space-y-4">
{workflowRunIsNotFinalized && (
<div
key="stream"
className={cn(
"flex cursor-pointer rounded-lg border-2 bg-slate-elevation3 p-4 hover:border-slate-50",
{
"border-slate-50": selection === "stream",
},
)}
onClick={() => setActive("stream")}
>
<div className="flex items-center gap-2">
<DotFilledIcon className="h-6 w-6 text-destructive" />
Live
</div>
</div>
)}
{timeline.length === 0 && <div>Workflow timeline is empty</div>}
{timeline?.map((timelineItem) => {
if (isBlockItem(timelineItem)) {
return (
<WorkflowRunTimelineBlockItem
key={timelineItem.block.workflow_run_block_id}
subBlocks={timelineItem.children
.filter((item) => item.type === "block")
.map((item) => item.block)}
activeItem={selection}
block={timelineItem.block}
onActionClick={setActive}
onBlockItemClick={setActive}
/>
);
}
if (isThoughtItem(timelineItem)) {
return (
<ThoughtCard
key={timelineItem.thought.observer_thought_id}
active={
isObserverThought(selection) &&
selection.observer_thought_id ===
timelineItem.thought.observer_thought_id
}
onClick={setActive}
thought={timelineItem.thought}
/>
);
}
})}
</div>
</ScrollAreaViewport>
</ScrollArea>
</div>
</div>
<AspectRatio ratio={16 / 9} className="overflow-y-hidden">
{selection === "stream" && <WorkflowRunStream />}
{selection !== "stream" && isAction(selection) && (
<ActionScreenshot
index={selection.action_order ?? 0}
stepId={selection.step_id ?? ""}
/>
)}
{isWorkflowRunBlock(selection) && (
<WorkflowRunBlockScreenshot
workflowRunBlockId={selection.workflow_run_block_id}
/>
)}
{isObserverThought(selection) && (
<ObserverThoughtScreenshot
observerThoughtId={selection.observer_thought_id}
/>
)}
</AspectRatio>
);
}

View File

@@ -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 <Skeleton className="h-full w-full" />;
}
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 (
<div className="min-w-0 space-y-4 rounded bg-slate-elevation1 p-4">
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Actions: {numberOfActions}
</div>
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Steps: {workflowRun.total_steps ?? 0}
</div>
<div className="flex items-center justify-center rounded bg-slate-elevation3 px-4 py-3 text-xs">
Cost: {formatter.format(workflowRun.total_cost ?? 0)}
</div>
</div>
<ScrollArea>
<ScrollAreaViewport className="max-h-[37rem]">
<div className="space-y-4">
{workflowRunIsNotFinalized && (
<div
key="stream"
className={cn(
"flex cursor-pointer rounded-lg border-2 bg-slate-elevation3 p-4 hover:border-slate-50",
{
"border-slate-50": activeItem === "stream",
},
)}
onClick={onLiveStreamSelected}
>
<div className="flex items-center gap-2">
<DotFilledIcon className="h-6 w-6 text-destructive" />
Live
</div>
</div>
)}
{timeline.length === 0 && <div>Workflow timeline is empty</div>}
{timeline?.map((timelineItem) => {
if (isBlockItem(timelineItem)) {
return (
<WorkflowRunTimelineBlockItem
key={timelineItem.block.workflow_run_block_id}
subBlocks={timelineItem.children
.filter((item) => item.type === "block")
.map((item) => item.block)}
activeItem={activeItem}
block={timelineItem.block}
onActionClick={onActionItemSelected}
onBlockItemClick={onBlockItemSelected}
/>
);
}
if (isThoughtItem(timelineItem)) {
return (
<ThoughtCard
key={timelineItem.thought.observer_thought_id}
active={
isObserverThought(activeItem) &&
activeItem.observer_thought_id ===
timelineItem.thought.observer_thought_id
}
onClick={onObserverThoughtCardSelected}
thought={timelineItem.thought}
/>
);
}
})}
</div>
</ScrollAreaViewport>
</ScrollArea>
</div>
);
}
export { WorkflowRunTimeline };

View File

@@ -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 (
<div className="space-y-4 rounded border border-slate-600 p-4">
<div
className={cn(
"cursor-pointer space-y-4 rounded border border-slate-600 p-4",
{
"border-slate-50":
isWorkflowRunBlock(activeItem) &&
activeItem.workflow_run_block_id === block.workflow_run_block_id,
},
)}
onClick={(event) => {
event.stopPropagation();
onBlockItemClick(block);
}}
ref={refCallback}
>
<div className="flex justify-between">
<div className="flex gap-3">
<WorkflowBlockIcon
workflowBlockType={block.block_type}
className="size-6"
/>
<span>{workflowBlockTitle[block.block_type]}</span>
</div>
<div className="flex items-center gap-1 rounded bg-slate-elevation5 px-2 py-1">
{showDiagnosticLink ? (
<Link to={`/tasks/${block.task_id}/diagnostics`}>
<div className="flex gap-1">
<ExternalLinkIcon className="size-4" />
<span className="text-xs">Diagnostics</span>
</div>
</Link>
) : (
<>
<CubeIcon className="size-4" />
<span className="text-xs">Block</span>
</>
)}
</div>
</div>
{actions.map((action, index) => {
return (
<ActionCard
key={action.action_id}
action={action}
active={
isActionItem(activeItem) &&
activeItem.action.action_id === action.action_id
isAction(activeItem) && activeItem.action_id === action.action_id
}
index={actions.length - index}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
const actionItem: ActionItem = {
block,
action,
@@ -61,16 +134,6 @@ function WorkflowRunTimelineBlockItem({
/>
);
})}
<BlockCard
active={
isWorkflowRunBlock(activeItem) &&
activeItem.workflow_run_block_id === block.workflow_run_block_id
}
block={block}
onClick={() => {
onBlockItemClick(block);
}}
/>
</div>
);
}

View File

@@ -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 (
<SendEmailBlockInfo body={item.body} recipients={item.recipients} />
<SendEmailBlockParameters
body={item.body}
recipients={item.recipients}
subject={item.subject}
/>
);
}
return null;

View File

@@ -1,29 +1,35 @@
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { Input } from "@/components/ui/input";
type Props = {
recipients: Array<string>;
body: string;
subject: string;
};
function SendEmailBlockInfo({ recipients, body }: Props) {
function SendEmailBlockParameters({ recipients, body, subject }: Props) {
return (
<div className="flex gap-2">
<div className="w-1/2 space-y-4 p-4">
<div className="flex justify-between">
<span className="text-sm text-slate-400">From</span>
<span className="text-sm">hello@skyvern.com</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-slate-400">To</span>
{recipients.map((recipient) => {
return <span className="text-sm">{recipient}</span>;
})}
<div className="space-y-4">
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">To</h1>
</div>
<Input value={recipients.join(", ")} readOnly />
</div>
<div className="w-1/2 space-y-4 p-4">
<span className="text-sm text-slate-400">Body</span>
<p className="text-sm">{body}</p>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Subject</h1>
</div>
<Input value={subject} readOnly />
</div>
<div className="flex gap-16">
<div className="w-80">
<h1 className="text-lg">Body</h1>
</div>
<AutoResizingTextarea value={body} readOnly />
</div>
</div>
);
}
export { SendEmailBlockInfo };
export { SendEmailBlockParameters };

View File

@@ -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 };

View File

@@ -0,0 +1,93 @@
import {
isBlockItem,
isThoughtItem,
WorkflowRunBlock,
WorkflowRunTimelineItem,
} from "../types/workflowRunTypes";
import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
function findBlockSurroundingAction(
timeline: Array<WorkflowRunTimelineItem>,
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<WorkflowRunTimelineItem>,
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 };