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

@@ -213,6 +213,7 @@ export type WorkflowRunStatusApiResponse = {
downloaded_file_urls: Array<string> | null;
total_steps: number | null;
total_cost: number | null;
observer_cruise: ObserverCruise | null;
};
export type TaskGenerationApiResponse = {

View File

@@ -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 (
<div className="flex w-fit gap-2 rounded-sm border border-slate-700 p-2">
{options.map((option) => {
return (
<NavLink
to={option.to}
to={`${option.to}?${searchParams.toString()}`}
replace
key={option.to}
className={({ isActive }) => {

View File

@@ -0,0 +1,26 @@
type Props = {
className?: string;
};
function BrainIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className={className}
>
<path
d="M16.9979 7.127C17.3193 7.04234 17.6533 7 17.9999 7C18.5686 7.00059 19.1306 7.12242 19.6485 7.35737C20.1663 7.59232 20.6281 7.93499 21.0031 8.36253C21.3781 8.79006 21.6576 9.29263 21.8229 9.83672C21.9883 10.3808 22.0358 10.9539 21.9622 11.5178C21.8886 12.0817 21.6956 12.6234 21.396 13.1068C21.0965 13.5902 20.6974 14.0042 20.2252 14.3211C19.7531 14.638 19.2188 14.8507 18.658 14.9448C18.0971 15.039 17.5227 15.0124 16.9729 14.867M16.9979 7.127L16.9999 7C17.0015 6.01205 16.6374 5.05848 15.9777 4.323C15.3181 3.58752 14.4096 3.12218 13.4273 3.01662C12.445 2.91106 11.4584 3.17276 10.6576 3.7513C9.85673 4.32984 9.29833 5.18428 9.08994 6.15M16.9979 7.127C16.9773 7.78571 16.7942 8.42911 16.4649 9M16.9729 14.867C16.9909 14.747 16.9999 14.6247 16.9999 14.5C17.0001 13.9237 16.801 13.365 16.4366 12.9186C16.0721 12.4721 15.5646 12.1653 14.9999 12.05M16.9729 14.867C16.8849 15.46 16.5868 16.0016 16.1329 16.3931C15.6789 16.7846 15.0994 17 14.4999 17H13.9999C12.9391 17 11.9217 17.4214 11.1715 18.1716C10.4214 18.9217 9.99994 19.9391 9.99994 21M9.08994 6.15C8.40228 5.95474 7.67487 5.94733 6.98338 6.12853C6.29188 6.30973 5.66158 6.67293 5.15806 7.18033C4.65453 7.68774 4.29619 8.3208 4.1203 9.01367C3.94441 9.70653 3.9574 10.4339 4.15794 11.12M9.08994 6.15C10.0923 6.43386 10.9444 7.09759 11.4649 8M4.15794 11.12C3.46598 11.3236 2.87149 11.7695 2.48147 12.3763C2.09145 12.983 1.933 13.7099 2.03512 14.4239C2.13725 15.1379 2.49311 15.7913 3.03757 16.2643C3.58203 16.7374 4.27866 16.9986 4.99994 17C5.62059 17.0003 6.22607 16.8081 6.73292 16.4499C7.23977 16.0917 7.62305 15.5852 7.82994 15M4.15794 11.12C4.24861 11.4313 4.37494 11.7247 4.53494 12M11.8359 11.744C11.3259 12.235 10.4529 12.32 9.70694 11.901C8.96094 11.481 8.57994 10.691 8.73494 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export { BrainIcon };

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