From 2f87e3ab4830d4fd5d83c183ac85db5eff01d3df Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Fri, 24 Oct 2025 21:12:08 -0400 Subject: [PATCH] FE implementation of InteractionNode (#3821) --- skyvern-frontend/src/api/types.ts | 1 + .../src/components/ui/button-variants.ts | 2 +- skyvern-frontend/src/routes/tasks/types.ts | 3 +- .../HumanInteractionNode.tsx | 245 ++++++++++++++++++ .../nodes/HumanInteractionNode/types.ts | 56 ++++ .../editor/nodes/WorkflowBlockIcon.tsx | 4 + .../routes/workflows/editor/nodes/index.ts | 4 + .../routes/workflows/editor/nodes/types.ts | 1 + .../panels/WorkflowNodeLibraryPanel.tsx | 11 + .../workflows/editor/workflowEditorUtils.ts | 76 +++++- .../routes/workflows/types/workflowTypes.ts | 16 ++ .../workflows/types/workflowYamlTypes.ts | 15 ++ .../WorkflowRunHumanInteraction.tsx | 115 ++++++++ .../WorkflowRunTimelineBlockItem.tsx | 54 +++- 14 files changed, 594 insertions(+), 9 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/types.ts create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunHumanInteraction.tsx diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 53614521..7c8d39d0 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -26,6 +26,7 @@ export const Status = { TimedOut: "timed_out", Canceled: "canceled", Skipped: "skipped", + Paused: "paused", } as const; export type Status = (typeof Status)[keyof typeof Status]; diff --git a/skyvern-frontend/src/components/ui/button-variants.ts b/skyvern-frontend/src/components/ui/button-variants.ts index f372ef87..55fbe60b 100644 --- a/skyvern-frontend/src/components/ui/button-variants.ts +++ b/skyvern-frontend/src/components/ui/button-variants.ts @@ -8,7 +8,7 @@ const buttonVariants = cva( default: "bg-primary text-primary-foreground shadow hover:bg-primary/90 font-bold", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-red-900 text-destructive-foreground shadow-sm hover:bg-destructive/90", disabled: "hover:bg-accent hover:text-accent-foreground opacity-50 pointer-events-none", outline: diff --git a/skyvern-frontend/src/routes/tasks/types.ts b/skyvern-frontend/src/routes/tasks/types.ts index d8712b5b..289f771a 100644 --- a/skyvern-frontend/src/routes/tasks/types.ts +++ b/skyvern-frontend/src/routes/tasks/types.ts @@ -21,7 +21,8 @@ export function statusIsNotFinalized({ status }: { status: Status }): boolean { return ( status === Status.Created || status === Status.Queued || - status === Status.Running + status === Status.Running || + status === Status.Paused ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx new file mode 100644 index 00000000..948894a7 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx @@ -0,0 +1,245 @@ +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { Handle, NodeProps, Position } from "@xyflow/react"; +import { type HumanInteractionNode } from "./types"; +import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; +import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; +import { cn } from "@/util/utils"; +import { NodeHeader } from "../components/NodeHeader"; +import { useParams } from "react-router-dom"; +import { statusIsRunningOrQueued } from "@/routes/tasks/types"; +import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useUpdate } from "@/routes/workflows/editor/useUpdate"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { useRerender } from "@/hooks/useRerender"; + +const instructionsTooltip = + "Instructions shown to the user for review. Explain what needs to be reviewed and what action should be taken."; +const positiveDescriptorTooltip = + "Label for the positive action button (e.g., 'Approve', 'Continue', 'Yes')."; +const negativeDescriptorTooltip = + "Label for the negative action button (e.g., 'Reject', 'Cancel', 'No')."; +const timeoutTooltip = + "Time in seconds to wait for human interaction before timing out. Default is 2 hours (7200 seconds)."; + +function HumanInteractionNode({ + id, + data, + type, +}: NodeProps) { + const { editable, label } = data; + const { blockLabel: urlBlockLabel } = useParams(); + const { data: workflowRun } = useWorkflowRunQuery(); + const workflowRunIsRunningOrQueued = + workflowRun && statusIsRunningOrQueued(workflowRun); + const thisBlockIsTargetted = + urlBlockLabel !== undefined && urlBlockLabel === label; + const thisBlockIsPlaying = + workflowRunIsRunningOrQueued && thisBlockIsTargetted; + const update = useUpdate({ id, editable }); + const rerender = useRerender({ prefix: "accordian" }); + + return ( +
+ + +
+ +
+
+
+
+ + +
+
+ {/* TODO(jdo): 'instructions' allows templating; but it requires adding a column to the workflow_block_runs + table, and I don't want to do that just yet (see /timeline endpoint) */} + { + update({ instructions: event.target.value }); + }} + value={data.instructions} + placeholder="Please review and approve or reject to continue the workflow." + className="nopan text-xs" + /> +
+
+
+
+ + +
+ { + update({ negativeDescriptor: event.target.value }); + }} + value={data.negativeDescriptor} + placeholder="Reject" + className="nopan text-xs" + /> +
+
+
+ + +
+ { + update({ positiveDescriptor: event.target.value }); + }} + value={data.positiveDescriptor} + placeholder="Approve" + className="nopan text-xs" + /> +
+
+ {/*
+
+ + +
+ { + if (!editable) { + return; + } + const value = Number(event.target.value); + update({ timeoutSeconds: value }); + }} + placeholder="7200" + /> +
*/} +
+ + + { + if (!editable) { + return; + } + const value = Number(event.target.value); + update({ timeoutSeconds: value }); + }} + /> +
+
+
+ Tip: The workflow will pause and send an email notification to the + recipients. The workflow continues or terminates based on the + user's response. +
+
+
+ + rerender.bump()} + collapsible + > + + Email Settings + +
+
+ + { + update({ recipients: value }); + }} + value={data.recipients} + placeholder="example@gmail.com, example2@gmail.com..." + className="nopan text-xs" + /> +
+
+ + { + update({ subject: value }); + }} + value={data.subject} + placeholder="Human interaction required for workflow run" + className="nopan text-xs" + /> +
+
+ + { + update({ body: value }); + }} + value={data.body} + placeholder="Your interaction is required for a workflow run!" + className="nopan text-xs" + /> +
+
+
+
+
+
+
+ ); +} + +export { HumanInteractionNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/types.ts new file mode 100644 index 00000000..82b712b6 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/types.ts @@ -0,0 +1,56 @@ +import type { Node } from "@xyflow/react"; +import { + EMAIL_BLOCK_SENDER, + SMTP_HOST_PARAMETER_KEY, + SMTP_PASSWORD_PARAMETER_KEY, + SMTP_PORT_PARAMETER_KEY, + SMTP_USERNAME_PARAMETER_KEY, +} from "../../constants"; +import { NodeBaseData } from "../types"; +import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes"; + +export type HumanInteractionNodeData = NodeBaseData & { + instructions: string; + positiveDescriptor: string; + negativeDescriptor: string; + timeoutSeconds: number; + recipients: string; + subject: string; + body: string; + sender: string; + smtpHostSecretParameterKey?: string; + smtpPortSecretParameterKey?: string; + smtpUsernameSecretParameterKey?: string; + smtpPasswordSecretParameterKey?: string; +}; + +export type HumanInteractionNode = Node< + HumanInteractionNodeData, + "human_interaction" +>; + +export const humanInteractionNodeDefaultData: HumanInteractionNodeData = { + debuggable: debuggableWorkflowBlockTypes.has("human_interaction"), + instructions: "Please review and approve or reject to continue the workflow.", + positiveDescriptor: "Approve", + negativeDescriptor: "Reject", + timeoutSeconds: 60 * 60 * 2, // two hours + recipients: "", + subject: "Human interaction required for workflow run", + body: "Your interaction is required for a workflow run!", + editable: true, + label: "", + sender: EMAIL_BLOCK_SENDER, + smtpHostSecretParameterKey: SMTP_HOST_PARAMETER_KEY, + smtpPortSecretParameterKey: SMTP_PORT_PARAMETER_KEY, + smtpUsernameSecretParameterKey: SMTP_USERNAME_PARAMETER_KEY, + smtpPasswordSecretParameterKey: SMTP_PASSWORD_PARAMETER_KEY, + continueOnFailure: false, + model: null, +} as const; + +export function isHumanInteractionNode( + node: Node, +): node is HumanInteractionNode { + return node.type === "human_interaction"; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx index 98f3b0f7..cf338882 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx @@ -9,6 +9,7 @@ import { ExternalLinkIcon, FileTextIcon, GlobeIcon, + HandIcon, ListBulletIcon, LockOpen1Icon, StopwatchIcon, @@ -71,6 +72,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) { case "validation": { return ; } + case "human_interaction": { + return ; + } case "wait": { return ; } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 0e5881df..719fb615 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -43,6 +43,8 @@ import { URLNode } from "./URLNode/types"; import { URLNode as URLNodeComponent } from "./URLNode/URLNode"; import { HttpRequestNode } from "./HttpRequestNode/types"; import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode"; +import { HumanInteractionNode } from "./HumanInteractionNode/types"; +import { HumanInteractionNode as HumanInteractionNodeComponent } from "./HumanInteractionNode/HumanInteractionNode"; export type UtilityNode = StartNode | NodeAdderNode; @@ -57,6 +59,7 @@ export type WorkflowBlockNode = | FileUploadNode | DownloadNode | ValidationNode + | HumanInteractionNode | ActionNode | NavigationNode | ExtractionNode @@ -93,6 +96,7 @@ export const nodeTypes = { validation: memo(ValidationNodeComponent), action: memo(ActionNodeComponent), navigation: memo(NavigationNodeComponent), + human_interaction: memo(HumanInteractionNodeComponent), extraction: memo(ExtractionNodeComponent), login: memo(LoginNodeComponent), wait: memo(WaitNodeComponent), diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index 58cb8bca..9ec3f42d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -50,6 +50,7 @@ export const workflowBlockTitle: { upload_to_s3: "Upload To S3", file_upload: "Cloud Storage", validation: "Validation", + human_interaction: "Human Interaction", wait: "Wait", pdf_parser: "PDF Parser", task_v2: "Browser Task v2", diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 586712ec..cdad3d2d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -87,6 +87,17 @@ const nodeLibraryItems: Array<{ title: "Validation Block", description: "Validate completion criteria", }, + { + nodeType: "human_interaction", + icon: ( + + ), + title: "Human Interaction Block", + description: "Validate via human interaction", + }, // { // nodeType: "task", // icon: ( diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 797dbccb..3d21acb8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -31,6 +31,7 @@ import { TextPromptBlockYAML, UploadToS3BlockYAML, ValidationBlockYAML, + HumanInteractionBlockYAML, NavigationBlockYAML, WorkflowCreateYAMLRequest, ExtractionBlockYAML, @@ -87,6 +88,10 @@ import { isValidationNode, validationNodeDefaultData, } from "./nodes/ValidationNode/types"; +import { + isHumanInteractionNode, + humanInteractionNodeDefaultData, +} from "./nodes/HumanInteractionNode/types"; import { actionNodeDefaultData, isActionNode } from "./nodes/ActionNode/types"; import { isNavigationNode, @@ -336,6 +341,24 @@ function convertToNode( }, }; } + case "human_interaction": { + return { + ...identifiers, + ...common, + type: "human_interaction", + data: { + ...commonData, + instructions: block.instructions, + positiveDescriptor: block.positive_descriptor, + negativeDescriptor: block.negative_descriptor, + timeoutSeconds: block.timeout_seconds, + recipients: block.recipients.join(", "), + subject: block.subject, + body: block.body, + sender: block.sender, + }, + }; + } case "extraction": { return { ...identifiers, @@ -831,6 +854,17 @@ function createNode( }, }; } + case "human_interaction": { + return { + ...identifiers, + ...common, + type: "human_interaction", + data: { + ...humanInteractionNodeDefaultData, + label, + }, + }; + } case "action": { return { ...identifiers, @@ -1105,6 +1139,22 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { parameter_keys: node.data.parameterKeys, }; } + case "human_interaction": { + return { + ...base, + block_type: "human_interaction", + instructions: node.data.instructions, + positive_descriptor: node.data.positiveDescriptor, + negative_descriptor: node.data.negativeDescriptor, + timeout_seconds: node.data.timeoutSeconds, + recipients: node.data.recipients + .split(",") + .map((recipient) => recipient.trim()), + subject: node.data.subject, + body: node.data.body, + sender: node.data.sender === "" ? EMAIL_BLOCK_SENDER : node.data.sender, + }; + } case "action": { return { ...base, @@ -1342,7 +1392,9 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { }; } default: { - throw new Error("Invalid node type for getWorkflowBlock"); + throw new Error( + `Invalid node type, '${node.type}', for getWorkflowBlock`, + ); } } } @@ -1910,6 +1962,23 @@ function convertBlocksToBlockYAML( }; return blockYaml; } + case "human_interaction": { + const blockYaml: HumanInteractionBlockYAML = { + ...base, + block_type: "human_interaction", + // -- + instructions: block.instructions, + positive_descriptor: block.positive_descriptor, + negative_descriptor: block.negative_descriptor, + timeout_seconds: block.timeout_seconds, + // -- + sender: block.sender, + recipients: block.recipients, + subject: block.subject, + body: block.body, + }; + return blockYaml; + } case "action": { const blockYaml: ActionBlockYAML = { ...base, @@ -2248,6 +2317,11 @@ function getWorkflowErrors(nodes: Array): Array { } }); + const interactionNodes = nodes.filter(isHumanInteractionNode); + interactionNodes.forEach((/* node */) => { + // pass for now + }); + const navigationNodes = nodes.filter(isNavigationNode); navigationNodes.forEach((node) => { if (node.data.navigationGoal.length === 0) { diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 170b9839..4b7e7e85 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -200,6 +200,7 @@ export type WorkflowBlock = | SendEmailBlock | FileURLParserBlock | ValidationBlock + | HumanInteractionBlock | ActionBlock | NavigationBlock | ExtractionBlock @@ -222,6 +223,7 @@ export const WorkflowBlockTypes = { SendEmail: "send_email", FileURLParser: "file_url_parser", Validation: "validation", + HumanInteraction: "human_interaction", Action: "action", Navigation: "navigation", Extraction: "extraction", @@ -396,6 +398,20 @@ export type ValidationBlock = WorkflowBlockBase & { disable_cache?: boolean; }; +export type HumanInteractionBlock = WorkflowBlockBase & { + block_type: "human_interaction"; + + instructions: string; + positive_descriptor: string; + negative_descriptor: string; + timeout_seconds: number; + + sender: string; + recipients: Array; + subject: string; + body: string; +}; + export type ActionBlock = WorkflowBlockBase & { block_type: "action"; url: string | null; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index b548833b..f4674882 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -127,6 +127,7 @@ export type BlockYAML = | FileUrlParserBlockYAML | ForLoopBlockYAML | ValidationBlockYAML + | HumanInteractionBlockYAML | ActionBlockYAML | NavigationBlockYAML | ExtractionBlockYAML @@ -185,6 +186,20 @@ export type ValidationBlockYAML = BlockYAMLBase & { parameter_keys?: Array | null; }; +export type HumanInteractionBlockYAML = BlockYAMLBase & { + block_type: "human_interaction"; + + instructions: string; + positive_descriptor: string; + negative_descriptor: string; + timeout_seconds: number; + + sender: string; + recipients: Array; + subject: string; + body: string; +}; + export type ActionBlockYAML = BlockYAMLBase & { block_type: "action"; url: string | null; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunHumanInteraction.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunHumanInteraction.tsx new file mode 100644 index 00000000..105bdc46 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunHumanInteraction.tsx @@ -0,0 +1,115 @@ +import { getClient } from "@/api/AxiosClient"; +import { Status as WorkflowRunStatus } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { cn } from "@/util/utils"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { toast } from "@/components/ui/use-toast"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { HumanInteractionBlock } from "../types/workflowTypes"; + +interface Props { + humanInteractionBlock: HumanInteractionBlock; +} + +export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { data: workflowRun } = useWorkflowRunQuery(); + const isPaused = + workflowRun && workflowRun.status === WorkflowRunStatus.Paused; + + const buttonLayout = + humanInteractionBlock.positive_descriptor.length < 8 && + humanInteractionBlock.negative_descriptor.length < 8 + ? "inline" + : "stacked"; + + const approveMutation = useMutation({ + mutationFn: async () => { + if (!workflowRun) { + return; + } + + const client = await getClient(credentialGetter, "sans-api-v1"); + + return await client.post( + `/workflows/runs/${workflowRun.workflow_run_id}/continue`, + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workflowRun"], + }); + + toast({ + variant: "success", + title: `${humanInteractionBlock.positive_descriptor}`, + description: `Successfully chose: ${humanInteractionBlock.positive_descriptor}`, + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Interaction Failed", + description: error.message, + }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: async () => { + if (!workflowRun) { + return; + } + + const client = await getClient(credentialGetter); + + return await client.post( + `/workflows/runs/${workflowRun.workflow_run_id}/cancel`, + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workflowRun"], + }); + + toast({ + variant: "success", + title: `${humanInteractionBlock.negative_descriptor}`, + description: `Successfully chose: ${humanInteractionBlock.negative_descriptor}`, + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Interaction Failed", + description: error.message, + }); + }, + }); + + if (!isPaused) { + return null; + } + + return ( +
+
{humanInteractionBlock.instructions}
+
+ + +
+
+ ); +} diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index cb5b879a..443c609d 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -4,6 +4,12 @@ import { CubeIcon, ExternalLinkIcon, } from "@radix-ui/react-icons"; +import { useCallback } from "react"; +import { useParams, Link } from "react-router-dom"; + +import { Status } from "@/api/types"; +import { formatDuration, toDuration } from "@/routes/workflows/utils"; +import { cn } from "@/util/utils"; import { workflowBlockTitle } from "../editor/nodes/types"; import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; import { @@ -20,14 +26,16 @@ 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"; -import { Status } from "@/api/types"; -import { formatDuration, toDuration } from "@/routes/workflows/utils"; import { ThoughtCard } from "./ThoughtCard"; +import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; import { ObserverThought } from "../types/workflowRunTypes"; +import { + HumanInteractionBlock, + isTaskVariantBlock, + type WorkflowApiResponse, +} from "../types/workflowTypes"; +import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction"; + type Props = { activeItem: WorkflowRunOverviewActiveElement; block: WorkflowRunBlock; @@ -37,6 +45,28 @@ type Props = { onThoughtCardClick: (thought: ObserverThought) => void; }; +const getHumanInteractionBlock = ( + block: WorkflowRunBlock, + workflow?: WorkflowApiResponse, +): HumanInteractionBlock | null => { + if (block.block_type !== "human_interaction") { + return null; + } + + if (!workflow) { + return null; + } + + const blocks = workflow.workflow_definition.blocks; + const candidate = blocks.find((b) => b.label === block.label); + + if (!candidate || candidate.block_type !== "human_interaction") { + return null; + } + + return candidate as HumanInteractionBlock; +}; + function WorkflowRunTimelineBlockItem({ activeItem, block, @@ -45,6 +75,12 @@ function WorkflowRunTimelineBlockItem({ onActionClick, onThoughtCardClick, }: Props) { + const { workflowPermanentId } = useParams(); + const { data: workflow } = useWorkflowQuery({ + workflowPermanentId, + }); + + const humanInteractionBlock = getHumanInteractionBlock(block, workflow); const actions = block.actions ?? []; const hasActiveAction = @@ -167,6 +203,12 @@ function WorkflowRunTimelineBlockItem({ ) : null} + {humanInteractionBlock && ( + + )} + {actions.map((action, index) => { return (