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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 13:53:01 -07:00
parent 211b18b073
commit 3dd02dd011
5 changed files with 303 additions and 90 deletions

View File

@@ -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.

View File

@@ -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<string | undefined>(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<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(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({
})()}
</div>
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
data-testid="inline-create-node-button"
onClick={() => {
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
}
return next;
});
}}
>
<Server size={12} style={{ verticalAlign: "middle" }} />
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
</button>
{showNodePicker && (
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
<button
type="button"
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
onClick={() => {
setNodeId(undefined);
setShowNodePicker(false);
}}
>
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
</button>
{nodes.map((node) => (
{shouldShowNodePicker && (
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
data-testid="inline-create-node-button"
onClick={() => {
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
}
return next;
});
}}
>
<Server size={12} style={{ verticalAlign: "middle" }} />
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
</button>
{showNodePicker && (
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
<button
key={node.id}
type="button"
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
onClick={() => {
setNodeId(node.id);
setNodeId(undefined);
setShowNodePicker(false);
}}
>
<NodeHealthDot status={node.status} />
<span className="dep-dropdown-title">{node.name}</span>
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
</button>
))}
</div>
)}
</div>
{nodes.map((node) => (
<button
key={node.id}
type="button"
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
onClick={() => {
setNodeId(node.id);
setShowNodePicker(false);
}}
>
<NodeHealthDot status={node.status} />
<span className="dep-dropdown-title">{node.name}</span>
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
</button>
))}
</div>
)}
</div>
)}
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button

View File

@@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition } from "../api";
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api";
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
@@ -125,6 +125,14 @@ function resolveQuickAddWorkflowId(
return workflowOptions[0]?.id ?? null;
}
function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
/*
FNXC:QuickAddNodeRouting 2026-06-30-00:00:
Local-only projects 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 QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
const { t } = useTranslation("app");
const [description, setDescription] = useState(() => {
@@ -217,6 +225,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const submitInFlightRef = useRef(false);
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;
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
const [isRefining, setIsRefining] = useState(false);
@@ -224,6 +235,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const refineMenuPortalRef = useRef<HTMLDivElement>(null);
const [refineMenuPosition, setRefineMenuPosition] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
return;
}
setNodeId(undefined);
setShowNodePicker(false);
setNodePickerPosition(null);
}, [nodeId, selectedNode, shouldShowNodePicker]);
// Use parent-provided favorites when available, otherwise internal state
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
@@ -684,7 +705,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
...(isFastMode ? { executionMode: "fast" } : {}),
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
priority,
nodeId,
nodeId: effectiveNodeId,
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
});
if (createdTask && pendingImages.length > 0) {
@@ -730,7 +751,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
settings,
githubTrackingOverride,
priority,
nodeId,
effectiveNodeId,
pendingImages,
projectId,
addToast,
@@ -1582,7 +1603,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
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 projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault;
const githubToggleLabel = effectiveGithubTracking
@@ -2069,43 +2089,45 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
{modelMenuLabel}
</button>
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-sm dep-trigger"
data-testid="quick-entry-node-button"
onClick={() => {
setShowDeps(false);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
updateNodePickerPosition();
} else {
setNodePickerPosition(null);
}
return next;
});
}}
>
<Server size={12} style={{ verticalAlign: "middle" }} />
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
{selectedNode && (
<span className="quick-entry-node-status">
<NodeHealthDot status={selectedNode.status} showLabel />
</span>
)}
</button>
</div>
{shouldShowNodePicker && (
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-sm dep-trigger"
data-testid="quick-entry-node-button"
onClick={() => {
setShowDeps(false);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
updateNodePickerPosition();
} else {
setNodePickerPosition(null);
}
return next;
});
}}
>
<Server size={12} style={{ verticalAlign: "middle" }} />
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
{selectedNode && (
<span className="quick-entry-node-status">
<NodeHealthDot status={selectedNode.status} showLabel />
</span>
)}
</button>
</div>
)}
{showNodePicker && portalRoot && nodePickerPosition && createPortal(
{shouldShowNodePicker && showNodePicker && portalRoot && nodePickerPosition && createPortal(
<div
ref={nodePickerPortalRef}
className="dep-dropdown node-picker-dropdown node-picker-dropdown--portal"

View File

@@ -185,6 +185,19 @@ function createMockTask(overrides: Partial<Task> = {}): 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<ComponentProps<typeof InlineCreateCard>> = {},
@@ -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(<InlineCreateCard {...props} />);
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();

View File

@@ -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(<QuickEntryBox {...props} />);
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 });