diff --git a/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/__tests__/index.test.tsx b/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/__tests__/index.test.tsx new file mode 100644 index 0000000000..59ab75044f --- /dev/null +++ b/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/__tests__/index.test.tsx @@ -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: () =>
, +})); +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: () => , +})); +jest.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +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(); + 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(); + 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(); + expect(screen.queryByTestId("human-input-node-badge")).not.toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/index.tsx new file mode 100644 index 0000000000..60b7f38326 --- /dev/null +++ b/src/frontend/src/CustomNodes/GenericNode/components/HumanInputNodeBadge/index.tsx @@ -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 ( + + + + + e.preventDefault()} + > + + + + ); +} diff --git a/src/frontend/src/CustomNodes/GenericNode/index.tsx b/src/frontend/src/CustomNodes/GenericNode/index.tsx index ef12079655..9b4293e30f 100644 --- a/src/frontend/src/CustomNodes/GenericNode/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/index.tsx @@ -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} />
+ {!showNode && ( <> diff --git a/src/frontend/src/controllers/API/agui/human-input-card.ts b/src/frontend/src/controllers/API/agui/human-input-card.ts index b09f760d97..b89b4ecd14 100644 --- a/src/frontend/src/controllers/API/agui/human-input-card.ts +++ b/src/frontend/src/controllers/API/agui/human-input-card.ts @@ -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); } diff --git a/src/frontend/src/controllers/API/agui/run-flow-bridge.ts b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts index 6fa8777cd4..43a63a36b4 100644 --- a/src/frontend/src/controllers/API/agui/run-flow-bridge.ts +++ b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts @@ -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(); }; diff --git a/src/frontend/src/controllers/API/agui/use-restore-canvas-hitl.ts b/src/frontend/src/controllers/API/agui/use-restore-canvas-hitl.ts new file mode 100644 index 0000000000..1bdfad74f7 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/use-restore-canvas-hitl.ts @@ -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]); +} diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index c4477f4616..24cf83113a 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -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); diff --git a/src/frontend/src/stores/hitlStore.ts b/src/frontend/src/stores/hitlStore.ts new file mode 100644 index 0000000000..ee76094652 --- /dev/null +++ b/src/frontend/src/stores/hitlStore.ts @@ -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((set) => ({ + pending: null, + setPending: (pending) => set({ pending }), + clear: () => set({ pending: null }), +}));