hitl on canvas

This commit is contained in:
cristhianzl
2026-06-16 16:38:14 -03:00
parent 5c42b85f76
commit 161cd84865
8 changed files with 184 additions and 0 deletions

View File

@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import type { InteractiveContent } from "@/types/chat";
import { useHitlStore } from "@/stores/hitlStore";
import HumanInputNodeBadge from "../index";
jest.mock("@/components/core/chatComponents/HumanInputCard", () => ({
__esModule: true,
default: () => <div data-testid="hitl-card" />,
}));
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: () => <span />,
}));
jest.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const _content: InteractiveContent = {
type: "human_input",
kind: "node_input",
request_id: "HumanInput-abc:job-1",
prompt: "Approve?",
options: [{ action_id: "approve", label: "Approve" }],
allowed_decisions: ["approve"],
job_id: "job-1",
};
describe("HumanInputNodeBadge", () => {
afterEach(() => useHitlStore.getState().clear());
it("renders nothing when no node is awaiting input", () => {
render(<HumanInputNodeBadge nodeId="HumanInput-abc" />);
expect(screen.queryByTestId("human-input-node-badge")).not.toBeInTheDocument();
});
it("renders the badge + card on the node whose id matches the pending request", () => {
useHitlStore.getState().setPending({ nodeId: "HumanInput-abc", content: _content });
render(<HumanInputNodeBadge nodeId="HumanInput-abc" />);
expect(screen.getByTestId("human-input-node-badge")).toBeInTheDocument();
expect(screen.getByTestId("hitl-card")).toBeInTheDocument();
});
it("renders nothing on a node that is not the one awaiting input", () => {
useHitlStore.getState().setPending({ nodeId: "HumanInput-abc", content: _content });
render(<HumanInputNodeBadge nodeId="ChatOutput-xyz" />);
expect(screen.queryByTestId("human-input-node-badge")).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import HumanInputCard from "@/components/core/chatComponents/HumanInputCard";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { useHitlStore } from "@/stores/hitlStore";
import { usePlaygroundStore } from "@/stores/playgroundStore";
import { cn } from "@/utils/utils";
/**
* Canvas affordance for a paused Human Input node (LE-1603): a pulsing "awaiting input"
* badge that doubles as the anchor for a decision popover. The popover reuses the chat
* `HumanInputCard` (which self-resumes), so the human can resolve the pause from the
* canvas. Only the node whose id matches the pending request renders anything.
*/
export default function HumanInputNodeBadge({ nodeId }: { nodeId: string }) {
const pending = useHitlStore((state) =>
state.pending?.nodeId === nodeId ? state.pending : null,
);
// The Playground renders its own card; its anchored popover would otherwise portal
// over the open Playground. Only surface the canvas affordance when it is closed.
const playgroundOpen = usePlaygroundStore((state) => state.isOpen);
const [open, setOpen] = useState(false);
// Auto-open when this node starts awaiting; dismissing keeps the badge to reopen.
useEffect(() => {
if (pending && !playgroundOpen) setOpen(true);
}, [pending, playgroundOpen]);
if (!pending || playgroundOpen) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type="button"
data-testid="human-input-node-badge"
onClick={() => setOpen(true)}
className={cn(
"flex items-center gap-1 rounded-full border border-accent-amber-foreground/40",
"bg-accent-amber/20 px-2 py-0.5 text-xs font-medium text-accent-amber-foreground",
)}
>
<ForwardedIconComponent
name="CircleHelp"
className="h-3 w-3 animate-pulse"
/>
Awaiting input
</button>
</PopoverAnchor>
<PopoverContent
side="bottom"
align="start"
className="w-96 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<HumanInputCard content={pending.content} />
</PopoverContent>
</Popover>
);
}

View File

@ -31,6 +31,7 @@ import { processNodeAdvancedFields } from "../helpers/process-node-advanced-fiel
import useUpdateNodeCode from "../hooks/use-update-node-code";
import NodeDescription from "./components/NodeDescription";
import NodeLegacyComponent from "./components/NodeLegacyComponent";
import HumanInputNodeBadge from "./components/HumanInputNodeBadge";
import NodeName from "./components/NodeName";
import NodeOutputs from "./components/NodeOutputParameter/NodeOutputs";
import NodeUpdateComponent from "./components/NodeUpdateComponent";
@ -605,6 +606,7 @@ function GenericNode({
setHasChangedNodeDescription={setHasChangedNodeDescription}
/>
</div>
<HumanInputNodeBadge nodeId={data.id} />
</div>
{!showNode && (
<>

View File

@ -8,6 +8,7 @@
import { updateMessage } from "@/components/core/playgroundComponent/chat-view/utils/message-utils";
import { queryClient } from "@/contexts";
import useFlowStore from "@/stores/flowStore";
import { useHitlStore } from "@/stores/hitlStore";
import { useMessagesStore } from "@/stores/messagesStore";
import type { ContentBlock, InteractiveContent } from "@/types/chat";
import type { Message } from "@/types/messages";
@ -150,5 +151,7 @@ export function injectHumanInputCard(
// playground reads useMessagesStore. Write both so the card renders either way.
updateMessage(message);
useMessagesStore.getState().addMessage(message);
// Canvas: surface the pause on the node that requested it (request_id = node_id:run_id).
useHitlStore.getState().setPending({ nodeId: content.request_id.split(":")[0], content });
useFlowStore.getState().setAwaitingInput(true);
}

View File

@ -13,6 +13,7 @@ import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-v
import { BuildStatus } from "@/constants/enums";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import { useHitlStore } from "@/stores/hitlStore";
import type {
ChatInputType,
ChatOutputType,
@ -394,6 +395,8 @@ export async function consumeBackgroundEvents(
// isBuilding, so it must clear on suspend too — only awaitingInput differs.
flowStore.setIsBuilding(false);
flowStore.setAwaitingInput(suspended);
// The run ended (not parked at a pause): drop the canvas awaiting-input badge.
if (!suspended) useHitlStore.getState().clear();
flowStore.revertBuiltStatusFromBuilding();
};

View File

@ -0,0 +1,33 @@
import { useEffect } from "react";
import { useGetMessagesQuery } from "@/controllers/API/queries/messages";
import { useHitlStore } from "@/stores/hitlStore";
import type { Message } from "@/types/messages";
import { findHumanInputContent } from "./human-input-card";
/**
* Restore the canvas awaiting-input badge after a reload (LE-1603 reconnect): the
* pending state lives in memory and is lost on F5, but the pause is persisted as a
* chat message. Find the latest unanswered human_input card for the flow and re-arm
* the badge from it (no extra job-status query needed).
*/
export function useRestoreCanvasHitl(flowId: string | undefined): void {
const { data } = useGetMessagesQuery(
{ id: flowId ?? "", mode: "union" },
{ enabled: Boolean(flowId) },
);
useEffect(() => {
const rows =
(data as { rows?: { data?: Message[] } } | undefined)?.rows?.data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const content = findHumanInputContent(rows[i].content_blocks);
if (content && !content.submitted_action && content.request_id) {
useHitlStore.getState().setPending({
nodeId: content.request_id.split(":")[0],
content,
});
return;
}
}
}, [data]);
}

View File

@ -33,6 +33,7 @@ import {
FlowSidebarComponent,
} from "./components/flowSidebarComponent";
import MemoriesMainContent from "./components/MemoriesMainContent";
import { useRestoreCanvasHitl } from "@/controllers/API/agui/use-restore-canvas-hitl";
import Page from "./components/PageComponent";
import { FlowInsightsContent } from "./components/TraceComponent/FlowInsightsContent";
@ -98,6 +99,9 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const flows = useFlowsManagerStore((state) => state.flows);
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
// Restore the Human Input awaiting-input badge after a reload (LE-1603 reconnect).
useRestoreCanvasHitl(currentFlowId);
const updatedAt = currentSavedFlow?.updated_at;
const autoSaving = useFlowsManagerStore((state) => state.autoSaving);
const stopBuilding = useFlowStore((state) => state.stopBuilding);

View File

@ -0,0 +1,25 @@
import { create } from "zustand";
import type { InteractiveContent } from "@/types/chat";
/**
* Canvas-side human-in-the-loop state (LE-1603): the single node currently awaiting
* a human decision, so the Human Input node can show an awaiting badge + an anchored
* decision popover. Single slot — the default single-node slice pauses one node at a
* time; the badge component matches on `nodeId`.
*/
interface HitlPending {
nodeId: string;
content: InteractiveContent;
}
interface HitlStoreType {
pending: HitlPending | null;
setPending: (pending: HitlPending) => void;
clear: () => void;
}
export const useHitlStore = create<HitlStoreType>((set) => ({
pending: null,
setPending: (pending) => set({ pending }),
clear: () => set({ pending: null }),
}));