From 3dd02dd011d071481153fce6c3517a95afb49989 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 13:53:01 -0700 Subject: [PATCH] FN-7300: hide local-only Quick Add node pickers Hide the Quick Add node picker whenever local execution is the only available route. - Gate QuickEntryBox and InlineCreateCard node controls behind meaningful remote or multi-node choices. - Clear stale node overrides when the hidden picker no longer offers a valid selection. - Cover local-only and multi-node picker behavior in Quick Add component tests. - Add a patch changeset for the published Fusion package. Files changed: ...n-7300-hide-local-only-quick-add-node-button.md | 7 ++ .../dashboard/app/components/InlineCreateCard.tsx | 121 ++++++++++++--------- .../dashboard/app/components/QuickEntryBox.tsx | 102 ++++++++++------- .../components/__tests__/InlineCreateCard.test.tsx | 74 +++++++++++++ .../components/__tests__/QuickEntryBox.test.tsx | 89 +++++++++++++++ 5 files changed, 303 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-7300 Fusion-Task-Lineage: 83ef956f-7993-4f58-8497-49da15509c08 Co-authored-by: Fusion (runfusion.ai) --- ...0-hide-local-only-quick-add-node-button.md | 7 + .../app/components/InlineCreateCard.tsx | 121 ++++++++++-------- .../app/components/QuickEntryBox.tsx | 102 +++++++++------ .../__tests__/InlineCreateCard.test.tsx | 74 +++++++++++ .../__tests__/QuickEntryBox.test.tsx | 89 +++++++++++++ 5 files changed, 303 insertions(+), 90 deletions(-) create mode 100644 .changeset/fn-7300-hide-local-only-quick-add-node-button.md diff --git a/.changeset/fn-7300-hide-local-only-quick-add-node-button.md b/.changeset/fn-7300-hide-local-only-quick-add-node-button.md new file mode 100644 index 0000000000..651637712d --- /dev/null +++ b/.changeset/fn-7300-hide-local-only-quick-add-node-button.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Hide Quick Add node pickers when only local execution is available. +category: fix +dev: QuickEntryBox and InlineCreateCard now clear hidden stale node overrides before create submission. diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 81b9bab968..ebf302edba 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -1,5 +1,5 @@ import "./InlineCreateCard.css"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Brain, Link, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react"; @@ -75,6 +75,14 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri }; } +function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean { + /* + FNXC:QuickAddNodeRouting 2026-06-30-00:00: + Local-only inline quick-create should not show a Node button because the project-default route already means local execution. Keep the picker only when a remote or second registered node makes routing a real choice. + */ + return nodes.length > 1 || nodes.some((node) => node.type !== "local"); +} + export function InlineCreateCard({ tasks, onSubmit, @@ -103,6 +111,9 @@ export function InlineCreateCard({ const [showPresets, setShowPresets] = useState(false); const [nodeId, setNodeId] = useState(undefined); const { nodes } = useNodes(); + const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]); + const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined; + const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined; const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(undefined); const [executorProvider, setExecutorProvider] = useState(undefined); @@ -182,6 +193,15 @@ export function InlineCreateCard({ if (!showDeps) setDepSearch(""); }, [showDeps]); + useEffect(() => { + if (shouldShowNodePicker && (!nodeId || selectedNode)) { + return; + } + + setNodeId(undefined); + setShowNodePicker(false); + }, [nodeId, selectedNode, shouldShowNodePicker]); + useEffect(() => { if (!showAgentPicker) return; @@ -482,7 +502,7 @@ export function InlineCreateCard({ */ enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined, priority, - nodeId, + nodeId: effectiveNodeId, }; try { @@ -497,7 +517,7 @@ export function InlineCreateCard({ } await submitTask(input); - }, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, optionalSteps.length, enabledOptionalStepIds, priority, nodeId, projectId, addToast, submitTask]); + }, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]); const handleDuplicateProceed = useCallback(async () => { const matches = duplicateMatches; @@ -627,7 +647,6 @@ export function InlineCreateCard({ const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined; const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId; - const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined; const handleExecutorChange = useCallback((value: string) => { const next = parseModelSelection(value); @@ -937,59 +956,61 @@ export function InlineCreateCard({ })()} -
- - {showNodePicker && ( -
e.preventDefault()}> -
{t("inline.selectExecutionNode", "Select execution node")}
- - {nodes.map((node) => ( + {shouldShowNodePicker && ( +
+ + {showNodePicker && ( +
e.preventDefault()}> +
{t("inline.selectExecutionNode", "Select execution node")}
- ))} -
- )} -
+ {nodes.map((node) => ( + + ))} +
+ )} +
+ )}
-
- -
+ {shouldShowNodePicker && ( +
+ +
+ )} - {showNodePicker && portalRoot && nodePickerPosition && createPortal( + {shouldShowNodePicker && showNodePicker && portalRoot && nodePickerPosition && createPortal(
= {}): Task { }; } +function mockInlineNodes(nodes: Array<{ id: string; name: string; status: "online" | "offline" | "connecting" | "error"; type: "local" | "remote" }>) { + vi.mocked(useNodes).mockReturnValue({ + nodes: nodes.map((node) => ({ ...node, createdAt: "", updatedAt: "" })), + loading: false, + error: null, + refresh: vi.fn(), + register: vi.fn(), + update: vi.fn(), + unregister: vi.fn(), + healthCheck: vi.fn(), + }); +} + function renderCard( tasks: Task[] = [], overrides: Partial> = {}, @@ -1635,6 +1648,67 @@ describe("InlineCreateCard button visibility when collapsed", () => { describe("InlineCreateCard node override", () => { + it("hides the node picker affordance for local-only inline quick-create without shells", () => { + mockInlineNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + ]); + renderCard(); + expandCard(); + + expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument(); + expect(document.querySelector(".inline-create-card .node-trigger-wrap")).toBeNull(); + expect(document.querySelector(".node-picker-dropdown")).toBeNull(); + expect(screen.queryByText("Select execution node")).not.toBeInTheDocument(); + }); + + it("hides the node picker when inline quick-create has no registered nodes", () => { + mockInlineNodes([]); + renderCard(); + expandCard(); + + expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument(); + expect(document.querySelector(".inline-create-card .node-trigger-wrap")).toBeNull(); + }); + + it("shows the node picker when inline quick-create has a remote node choice", () => { + mockInlineNodes([ + { id: "remote", name: "Remote Only", status: "online", type: "remote" }, + ]); + renderCard(); + expandCard(); + + expect(screen.getByTestId("inline-create-node-button")).toBeInTheDocument(); + }); + + it("clears stale inline node override when nodes shrink to local-only before submit", async () => { + const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task); + mockInlineNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + { id: "remote", name: "Remote", status: "online", type: "remote" }, + ]); + const { rerender, props } = renderCard([], { onSubmit }); + + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inline node shrink" } }); + expandCard(); + fireEvent.click(screen.getByTestId("inline-create-node-button")); + fireEvent.click(screen.getByRole("button", { name: /Remote/i })); + expect(screen.getByTestId("inline-create-node-button")).toHaveTextContent("Remote"); + + mockInlineNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + ]); + rerender(); + + await waitFor(() => { + expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("save-button")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined })); + }); + }); + it("opens node picker from button", () => { renderCard(); expandCard(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 324ff697d1..c650e9c77c 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -279,6 +279,19 @@ vi.mock("../CustomModelDropdown", () => ({ ), })); +function mockQuickEntryNodes(nodes: Array<{ id: string; name: string; status: "online" | "offline" | "connecting" | "error"; type: "local" | "remote" }>) { + vi.mocked(useNodes).mockReturnValue({ + nodes: nodes.map((node) => ({ ...node, createdAt: "", updatedAt: "" })), + loading: false, + error: null, + refresh: vi.fn(), + register: vi.fn(), + update: vi.fn(), + unregister: vi.fn(), + healthCheck: vi.fn(), + }); +} + function renderQuickEntryBox(props = {}, { startExpanded = false } = {}) { // Legacy option retained for older test call sites; disclosure now defaults expanded. if (startExpanded) { @@ -4731,6 +4744,82 @@ describe("QuickEntryBox", () => { }); }); + it("hides the node picker affordance for local-only projects without leaving shells", () => { + mockQuickEntryNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + ]); + renderQuickEntryBox({}); + + expandQuickEntry(); + + expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument(); + expect(document.querySelector(".quick-entry-box .node-trigger-wrap")).toBeNull(); + expect(document.body.querySelector(".node-picker-dropdown")).toBeNull(); + expect(screen.queryByText("Select execution node")).not.toBeInTheDocument(); + }); + + it("hides the node picker when no registered nodes are available", () => { + mockQuickEntryNodes([]); + renderQuickEntryBox({}); + + expandQuickEntry(); + + expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument(); + expect(document.querySelector(".quick-entry-box .node-trigger-wrap")).toBeNull(); + }); + + it("shows the node picker when any remote node is available", () => { + mockQuickEntryNodes([ + { id: "remote", name: "Remote Only", status: "online", type: "remote" }, + ]); + renderQuickEntryBox({}); + + expandQuickEntry(); + + expect(screen.getByTestId("quick-entry-node-button")).toBeInTheDocument(); + }); + + it("shows the node picker when multiple local nodes are registered", () => { + mockQuickEntryNodes([ + { id: "local-a", name: "Local A", status: "online", type: "local" }, + { id: "local-b", name: "Local A", status: "offline", type: "local" }, + ]); + renderQuickEntryBox({}); + + expandQuickEntry(); + + expect(screen.getByTestId("quick-entry-node-button")).toBeInTheDocument(); + }); + + it("clears a stale selected node when the registry shrinks to local-only before submit", async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + mockQuickEntryNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + { id: "remote", name: "Remote", status: "online", type: "remote" }, + ]); + const { rerender, props } = renderQuickEntryBox({ onCreate }); + + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route after node shrink" } }); + expandQuickEntry(); + fireEvent.click(screen.getByTestId("quick-entry-node-button")); + fireEvent.click(screen.getByText("Remote")); + expect(screen.getByTestId("quick-entry-node-button")).toHaveTextContent("Remote"); + + mockQuickEntryNodes([ + { id: "local", name: "Local", status: "online", type: "local" }, + ]); + rerender(); + + await waitFor(() => { + expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument(); + }); + clickSave(); + + await waitFor(() => { + expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined })); + }); + }); + it("includes nodeId in payload when execution node override is selected", async () => { const onCreate = vi.fn().mockResolvedValue(undefined); renderQuickEntryBox({ onCreate });