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 }),
+}));