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; downloaded_file_urls: Array<string> | null;
total_steps: number | null; total_steps: number | null;
total_cost: number | null; total_cost: number | null;
observer_cruise: ObserverCruise | null;
}; };
export type TaskGenerationApiResponse = { export type TaskGenerationApiResponse = {

View File

@@ -1,5 +1,5 @@
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { NavLink } from "react-router-dom"; import { NavLink, useSearchParams } from "react-router-dom";
type Option = { type Option = {
label: string; label: string;
@@ -11,12 +11,14 @@ type Props = {
}; };
function SwitchBarNavigation({ options }: Props) { function SwitchBarNavigation({ options }: Props) {
const [searchParams] = useSearchParams();
return ( return (
<div className="flex w-fit gap-2 rounded-sm border border-slate-700 p-2"> <div className="flex w-fit gap-2 rounded-sm border border-slate-700 p-2">
{options.map((option) => { {options.map((option) => {
return ( return (
<NavLink <NavLink
to={option.to} to={`${option.to}?${searchParams.toString()}`}
replace replace
key={option.to} key={option.to}
className={({ isActive }) => { 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"; } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import fetchToCurl from "fetch-to-curl"; 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 { statusIsFinalized, statusIsRunningOrQueued } from "../tasks/types";
import { useWorkflowQuery } from "./hooks/useWorkflowQuery"; import { useWorkflowQuery } from "./hooks/useWorkflowQuery";
import { useWorkflowRunQuery } from "./hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "./hooks/useWorkflowRunQuery";
import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline";
import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery";
import { findActiveItem } from "./workflowRun/workflowTimelineUtils";
function WorkflowRun() { function WorkflowRun() {
const [searchParams, setSearchParams] = useSearchParams();
const active = searchParams.get("active");
const { workflowRunId, workflowPermanentId } = useParams(); const { workflowRunId, workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const apiCredential = useApiCredential(); const apiCredential = useApiCredential();
@@ -45,6 +50,8 @@ function WorkflowRun() {
const { data: workflowRun, isLoading: workflowRunIsLoading } = const { data: workflowRun, isLoading: workflowRunIsLoading } =
useWorkflowRunQuery(); useWorkflowRunQuery();
const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery();
const cancelWorkflowMutation = useMutation({ const cancelWorkflowMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const client = await getClient(credentialGetter); const client = await getClient(credentialGetter);
@@ -78,7 +85,11 @@ function WorkflowRun() {
workflowRun && statusIsRunningOrQueued(workflowRun); workflowRun && statusIsRunningOrQueued(workflowRun);
const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun); const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun);
const selection = findActiveItem(
workflowRunTimeline ?? [],
active,
!!workflowRunIsFinalized,
);
const parameters = workflowRun?.parameters ?? {}; const parameters = workflowRun?.parameters ?? {};
const proxyLocation = const proxyLocation =
workflowRun?.proxy_location ?? ProxyLocation.Residential; workflowRun?.proxy_location ?? ProxyLocation.Residential;
@@ -108,6 +119,13 @@ function WorkflowRun() {
</div> </div>
) : null; ) : null;
function handleSetActiveItem(id: string) {
searchParams.set("active", id);
setSearchParams(searchParams, {
replace: true,
});
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<header className="flex justify-between"> <header className="flex justify-between">
@@ -230,8 +248,29 @@ function WorkflowRun() {
}, },
]} ]}
/> />
<div className="flex h-[42rem] gap-6">
<div className="w-2/3">
<Outlet /> <Outlet />
</div> </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; wait_sec?: number | null;
created_at: string; created_at: string;
modified_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 = { export type WorkflowRunTimelineBlockItem = {

View File

@@ -3,21 +3,33 @@ import { Separator } from "@/components/ui/separator";
import { ActionTypePill } from "@/routes/tasks/detail/ActionTypePill"; import { ActionTypePill } from "@/routes/tasks/detail/ActionTypePill";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons";
import { useCallback } from "react";
type Props = { type Props = {
action: ActionsApiResponse; action: ActionsApiResponse;
index: number; index: number;
active: boolean; active: boolean;
onClick: () => void; onClick: React.DOMAttributes<HTMLDivElement>["onClick"];
}; };
function ActionCard({ action, onClick, active, index }: Props) { function ActionCard({ action, onClick, active, index }: Props) {
const success = action.status === Status.Completed; 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 ( return (
<div <div
className={cn( 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-destructive": !success,
"border-l-success": success, "border-l-success": success,
@@ -25,6 +37,7 @@ function ActionCard({ action, onClick, active, index }: Props) {
}, },
)} )}
onClick={onClick} onClick={onClick}
ref={refCallback}
> >
<div className="flex-1 space-y-2 p-4 pl-5"> <div className="flex-1 space-y-2 p-4 pl-5">
<div className="flex justify-between"> <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 { ObserverThought } from "../types/workflowRunTypes";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { BrainIcon } from "@/components/icons/BrainIcon";
import { useCallback } from "react";
type Props = { type Props = {
active: boolean; active: boolean;
@@ -9,6 +11,17 @@ type Props = {
}; };
function ThoughtCard({ thought, onClick, active }: 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 ( return (
<div <div
className={cn( className={cn(
@@ -20,11 +33,15 @@ function ThoughtCard({ thought, onClick, active }: Props) {
onClick={() => { onClick={() => {
onClick(thought); onClick(thought);
}} }}
ref={refCallback}
> >
<div className="flex justify-between"> <div className="flex justify-between">
<span>Thought</span> <div className="flex gap-3">
<div className="flex items-center gap-1 bg-slate-elevation5"> <BrainIcon className="size-6" />
<PersonIcon className="size-4" /> <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> <span className="text-xs">Decision</span>
</div> </div>
</div> </div>

View File

@@ -1,26 +1,105 @@
import { Label } from "@/components/ui/label";
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
import { CodeEditor } from "../components/CodeEditor"; import { CodeEditor } from "../components/CodeEditor";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; 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() { function WorkflowPostRunParameters() {
const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } =
useWorkflowRunTimelineQuery();
const [activeItem] = useActiveWorkflowRunItem();
const { data: workflowRun, isLoading: workflowRunIsLoading } = const { data: workflowRun, isLoading: workflowRunIsLoading } =
useWorkflowRunQuery(); useWorkflowRunQuery();
const parameters = workflowRun?.parameters ?? {}; const parameters = workflowRun?.parameters ?? {};
if (workflowRunIsLoading) { if (workflowRunIsLoading || workflowRunTimelineIsLoading) {
return <div>Loading workflow parameters...</div>; return <div>Loading workflow parameters...</div>;
} }
return Object.entries(parameters).length > 0 ? ( if (!workflowRun || !workflowRunTimeline) {
<div className="space-y-4 rounded-lg bg-slate-elevation3 px-6 py-5"> return null;
<header> }
<h2 className="text-lg font-semibold">Input Parameter Values</h2>
</header> 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]) => { {Object.entries(parameters).map(([key, value]) => {
return ( return (
<div key={key} className="space-y-2"> <div key={key} className="flex gap-16">
<Label className="text-lg">{key}</Label> <div className="w-80">
<h1 className="text-lg">{key}</h1>
</div>
{typeof value === "string" || {typeof value === "string" ||
typeof value === "number" || typeof value === "number" ||
typeof value === "boolean" ? ( typeof value === "boolean" ? (
@@ -31,17 +110,56 @@ function WorkflowPostRunParameters() {
readOnly readOnly
language="json" language="json"
minHeight="96px" minHeight="96px"
maxHeight="500px" maxHeight="200px"
className="w-full"
/> />
)} )}
</div> </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> </div>
) : (
Object.entries(parameters).length === 0 && (
<div>This workflow doesn't have any input parameters.</div>
)
); );
} }

View File

@@ -1,29 +1,112 @@
import { FileIcon } from "@radix-ui/react-icons"; import { FileIcon } from "@radix-ui/react-icons";
import { CodeEditor } from "../components/CodeEditor"; import { CodeEditor } from "../components/CodeEditor";
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; 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() { function WorkflowRunOutput() {
const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } =
useWorkflowRunTimelineQuery();
const [activeItem] = useActiveWorkflowRunItem();
const { data: workflowRun } = useWorkflowRunQuery(); 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 outputs = workflowRun?.outputs;
const fileUrls = workflowRun?.downloaded_file_urls ?? []; const fileUrls = workflowRun?.downloaded_file_urls ?? [];
return ( return (
<div className="space-y-5">
{activeBlock ? (
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4"> <div className="space-y-4">
<header> <h1 className="text-lg font-bold">Block Outputs</h1>
<h2 className="text-lg font-semibold">Workflow Run Output</h2> <div className="space-y-2">
</header> <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 <CodeEditor
language="json" language="json"
value={ value={
outputs ? JSON.stringify(outputs, null, 2) : "Waiting for outputs.." outputs
? JSON.stringify(outputs, null, 2)
: "Waiting for outputs.."
} }
readOnly readOnly
minHeight="96px" minHeight="96px"
maxHeight="500px" maxHeight="200px"
/> />
</div>
</div>
<div className="rounded bg-slate-elevation2 p-6">
<div className="space-y-4"> <div className="space-y-4">
<header> <h1 className="text-lg font-bold">Workflow Run Downloaded Files</h1>
<h2 className="text-lg font-semibold">Downloaded Files</h2>
</header>
<div className="space-y-2"> <div className="space-y-2">
{fileUrls.length > 0 ? ( {fileUrls.length > 0 ? (
fileUrls.map((url, index) => { fileUrls.map((url, index) => {
@@ -42,6 +125,7 @@ function WorkflowRunOutput() {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,35 +1,22 @@
import { ActionsApiResponse } from "@/api/types";
import { AspectRatio } from "@/components/ui/aspect-ratio"; 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 { ActionScreenshot } from "@/routes/tasks/detail/ActionScreenshot";
import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem"; import { statusIsFinalized } from "@/routes/tasks/types";
import { ThoughtCard } from "./ThoughtCard"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
import { import {
isActionItem, isAction,
isBlockItem,
isObserverThought, isObserverThought,
isTaskVariantBlockItem,
isThoughtItem,
isWorkflowRunBlock, isWorkflowRunBlock,
ObserverThought, ObserverThought,
WorkflowRunBlock, WorkflowRunBlock,
} from "../types/workflowRunTypes"; } 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 { ObserverThoughtScreenshot } from "./ObserverThoughtScreenshot";
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
import { WorkflowRunBlockScreenshot } from "./WorkflowRunBlockScreenshot"; import { WorkflowRunBlockScreenshot } from "./WorkflowRunBlockScreenshot";
import { WorkflowRunStream } from "./WorkflowRunStream";
const formatter = Intl.NumberFormat("en-US", { import { useSearchParams } from "react-router-dom";
style: "currency", import { findActiveItem } from "./workflowTimelineUtils";
currency: "USD", import { Skeleton } from "@/components/ui/skeleton";
});
export type ActionItem = { export type ActionItem = {
block: WorkflowRunBlock; block: WorkflowRunBlock;
@@ -37,14 +24,15 @@ export type ActionItem = {
}; };
export type WorkflowRunOverviewActiveElement = export type WorkflowRunOverviewActiveElement =
| ActionItem | ActionsApiResponse
| ObserverThought | ObserverThought
| WorkflowRunBlock | WorkflowRunBlock
| "stream" | "stream"
| null; | null;
function WorkflowRunOverview() { function WorkflowRunOverview() {
const [active, setActive] = useState<WorkflowRunOverviewActiveElement>(null); const [searchParams] = useSearchParams();
const active = searchParams.get("active");
const { data: workflowRun, isLoading: workflowRunIsLoading } = const { data: workflowRun, isLoading: workflowRunIsLoading } =
useWorkflowRunQuery(); useWorkflowRunQuery();
@@ -52,7 +40,11 @@ function WorkflowRunOverview() {
useWorkflowRunTimelineQuery(); useWorkflowRunTimelineQuery();
if (workflowRunIsLoading || workflowRunTimelineIsLoading) { if (workflowRunIsLoading || workflowRunTimelineIsLoading) {
return <WorkflowRunOverviewSkeleton />; return (
<AspectRatio ratio={16 / 9}>
<Skeleton className="h-full w-full" />
</AspectRatio>
);
} }
if (!workflowRun) { if (!workflowRun) {
@@ -63,57 +55,20 @@ function WorkflowRunOverview() {
return null; return null;
} }
const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun); const workflowRunIsFinalized = statusIsFinalized(workflowRun);
const selection = findActiveItem(
const timeline = workflowRunTimeline.slice().reverse(); workflowRunTimeline,
active,
function getActiveSelection(): WorkflowRunOverviewActiveElement { workflowRunIsFinalized,
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);
return ( return (
<div className="flex h-[42rem] gap-6">
<div className="w-2/3 space-y-4">
<AspectRatio ratio={16 / 9} className="overflow-y-hidden"> <AspectRatio ratio={16 / 9} className="overflow-y-hidden">
{selection === "stream" && <WorkflowRunStream />} {selection === "stream" && <WorkflowRunStream />}
{selection !== "stream" && isActionItem(selection) && ( {selection !== "stream" && isAction(selection) && (
<ActionScreenshot <ActionScreenshot
index={selection.action.action_order ?? 0} index={selection.action_order ?? 0}
stepId={selection.action.step_id ?? ""} stepId={selection.step_id ?? ""}
/> />
)} )}
{isWorkflowRunBlock(selection) && ( {isWorkflowRunBlock(selection) && (
@@ -127,77 +82,6 @@ function WorkflowRunOverview() {
/> />
)} )}
</AspectRatio> </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>
); );
} }

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 { import {
isActionItem, isAction,
isWorkflowRunBlock, isWorkflowRunBlock,
WorkflowRunBlock, WorkflowRunBlock,
} from "../types/workflowRunTypes"; } from "../types/workflowRunTypes";
import { ActionCard } from "./ActionCard"; import { ActionCard } from "./ActionCard";
import { BlockCard } from "./BlockCard";
import { import {
ActionItem, ActionItem,
WorkflowRunOverviewActiveElement, WorkflowRunOverviewActiveElement,
} from "./WorkflowRunOverview"; } from "./WorkflowRunOverview";
import { cn } from "@/util/utils";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { Link } from "react-router-dom";
import { useCallback } from "react";
type Props = { type Props = {
activeItem: WorkflowRunOverviewActiveElement; activeItem: WorkflowRunOverviewActiveElement;
@@ -27,19 +33,86 @@ function WorkflowRunTimelineBlockItem({
}: Props) { }: Props) {
const actions = block.actions ? [...block.actions].reverse() : []; 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 ( 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) => { {actions.map((action, index) => {
return ( return (
<ActionCard <ActionCard
key={action.action_id} key={action.action_id}
action={action} action={action}
active={ active={
isActionItem(activeItem) && isAction(activeItem) && activeItem.action_id === action.action_id
activeItem.action.action_id === action.action_id
} }
index={actions.length - index} index={actions.length - index}
onClick={() => { onClick={(event) => {
event.stopPropagation();
const actionItem: ActionItem = { const actionItem: ActionItem = {
block, block,
action, 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> </div>
); );
} }

View File

@@ -11,10 +11,10 @@ import { CodeEditor } from "../components/CodeEditor";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { WorkflowBlockTypes } from "../types/workflowTypes"; import { WorkflowBlockTypes } from "../types/workflowTypes";
import { statusIsAFailureType } from "@/routes/tasks/types"; import { statusIsAFailureType } from "@/routes/tasks/types";
import { SendEmailBlockInfo } from "./blockInfo/SendEmailBlockInfo";
import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
import { ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExternalLinkIcon } from "@radix-ui/react-icons";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { SendEmailBlockParameters } from "./blockInfo/SendEmailBlockInfo";
type Props = { type Props = {
activeItem: WorkflowRunOverviewActiveElement; activeItem: WorkflowRunOverviewActiveElement;
@@ -151,10 +151,16 @@ function WorkflowRunTimelineItemInfoSection({ activeItem }: Props) {
item.body !== null && item.body !== null &&
typeof item.body !== "undefined" && typeof item.body !== "undefined" &&
item.recipients !== null && item.recipients !== null &&
typeof item.recipients !== "undefined" typeof item.recipients !== "undefined" &&
item.subject !== null &&
typeof item.subject !== "undefined"
) { ) {
return ( return (
<SendEmailBlockInfo body={item.body} recipients={item.recipients} /> <SendEmailBlockParameters
body={item.body}
recipients={item.recipients}
subject={item.subject}
/>
); );
} }
return null; return null;

View File

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