FE implementation of InteractionNode (#3821)

This commit is contained in:
Jonathan Dobson
2025-10-24 21:12:08 -04:00
committed by GitHub
parent 454c00b10a
commit 2f87e3ab48
14 changed files with 594 additions and 9 deletions

View File

@@ -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 (
<div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4">
<div className="text-sm">{humanInteractionBlock.instructions}</div>
<div
className={cn("flex gap-2", {
"justify-between": buttonLayout === "inline",
"flex-col": buttonLayout === "stacked",
})}
>
<Button variant="destructive" onClick={() => rejectMutation.mutate()}>
<div>{humanInteractionBlock.negative_descriptor}</div>
</Button>
<Button variant="default" onClick={() => approveMutation.mutate()}>
<div>{humanInteractionBlock.positive_descriptor}</div>
</Button>
</div>
</div>
);
}

View File

@@ -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}
</div>
{humanInteractionBlock && (
<WorkflowRunHumanInteraction
humanInteractionBlock={humanInteractionBlock}
/>
)}
{actions.map((action, index) => {
return (
<ActionCard