feat(FN-2913): merge fusion/fn-2913

- feat(FN-2913): complete Step 7 — align InlineCreateCard node picker UX

Fusion-Task-Id: FN-2913
This commit is contained in:
Fusion
2026-04-29 00:08:21 -07:00
committed by gsxdsm
parent 5ef5efd7cd
commit d44b688260
7 changed files with 435 additions and 60 deletions

View File

@@ -215,14 +215,33 @@
align-items: center;
}
.inline-create-node-wrap {
.node-trigger-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.inline-create-node-select {
max-width: calc(var(--space-2xl) * 8);
.node-picker-dropdown {
width: max-content;
min-width: calc(var(--space-2xl) * 8);
}
.node-picker-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
text-align: left;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.node-picker-status-label {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.inline-create-node-status {

View File

@@ -1,7 +1,7 @@
import "./InlineCreateCard.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2 } from "lucide-react";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -98,6 +98,7 @@ export function InlineCreateCard({
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [showNodePicker, setShowNodePicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [showPresets, setShowPresets] = useState(false);
@@ -127,6 +128,7 @@ export function InlineCreateCard({
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const nodePickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
@@ -188,6 +190,19 @@ export function InlineCreateCard({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
useEffect(() => {
if (!showNodePicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (nodePickerRef.current?.contains(target)) return;
setShowNodePicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showNodePicker]);
useEffect(() => {
let cancelled = false;
@@ -374,6 +389,7 @@ export function InlineCreateCard({
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
@@ -422,8 +438,9 @@ export function InlineCreateCard({
return;
}
// Close dropdowns first if open
if (showDeps || showAgentPicker || isModelModalOpen || showPresets) {
if (showDeps || showNodePicker || showAgentPicker || isModelModalOpen || showPresets) {
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
@@ -457,6 +474,7 @@ export function InlineCreateCard({
description,
isDescriptionExpanded,
showDeps,
showNodePicker,
showAgentPicker,
isModelModalOpen,
showPresets,
@@ -475,6 +493,7 @@ export function InlineCreateCard({
const next = !prev;
if (next) {
setIsModelModalOpen(false);
setShowNodePicker(false);
setShowAgentPicker(false);
}
return next;
@@ -484,11 +503,13 @@ export function InlineCreateCard({
const toggleModelsDropdown = useCallback(() => {
setIsModelModalOpen(true);
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
}, []);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
setShowNodePicker(false);
setShowAgentPicker(true);
return;
}
@@ -497,6 +518,7 @@ export function InlineCreateCard({
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setShowNodePicker(false);
setShowAgentPicker(true);
} catch (err) {
const msg = getErrorMessage(err);
@@ -509,6 +531,7 @@ 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);
@@ -835,36 +858,66 @@ export function InlineCreateCard({
})()}
</div>
<label className="inline-create-node-wrap" htmlFor="inline-create-node-select">
<span className="visually-hidden">Execution Node Override</span>
<select
id="inline-create-node-select"
className="select inline-create-node-select"
data-testid="inline-create-node-select"
value={nodeId ?? ""}
onChange={(e) => {
const nextNodeId = e.target.value;
setNodeId(nextNodeId || undefined);
<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;
});
}}
>
<option value="">Execution Node: Project default / local</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{`${node.name} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<Server size={12} style={{ verticalAlign: "middle" }} />
{selectedNode ? ` ${selectedNode.name}` : " Node"}
{selectedNode && (
<span className={`inline-create-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="inline-create-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
</span>
);
})()}
</label>
)}
</button>
{showNodePicker && (
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">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">Project default / local</span>
</button>
{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);
}}
>
<span className={`inline-create-node-status ${getNodeStatusClass(node.status)}`}>
<span className="inline-create-node-status__dot" aria-hidden="true" />
</span>
<span className="dep-dropdown-title">{node.name}</span>
<span className="node-picker-status-label">{getNodeStatusLabel(node.status)}</span>
</button>
))}
</div>
)}
</div>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
@@ -957,6 +1010,7 @@ export function InlineCreateCard({
const next = !prev;
if (next) {
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
}

View File

@@ -117,14 +117,39 @@
position: relative;
}
.quick-entry-node-wrap {
.node-trigger-wrap {
position: relative;
display: inline-flex;
}
.node-picker-dropdown {
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
background: var(--surface);
z-index: 1000;
}
.node-picker-dropdown--portal {
position: fixed;
}
.node-picker-item {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.quick-entry-node-select {
max-width: calc(var(--space-2xl) * 8);
.node-picker-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-picker-item-status {
margin-left: auto;
color: var(--text-muted);
font-size: 12px;
}
.quick-entry-node-status {

View File

@@ -6,7 +6,7 @@ import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ModelInfo, RefinementType, Agent } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
@@ -119,6 +119,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [showNodePicker, setShowNodePicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
@@ -132,7 +133,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const agentPickerPortalRef = useRef<HTMLDivElement>(null);
const nodePickerRef = useRef<HTMLDivElement>(null);
const nodePickerPortalRef = useRef<HTMLDivElement>(null);
const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
// Dependency dropdown portal refs and state
const depTriggerRef = useRef<HTMLButtonElement>(null);
@@ -356,6 +360,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isModelMenuOpen]);
useEffect(() => {
if (!showNodePicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (nodePickerRef.current?.contains(target)) return;
if (nodePickerPortalRef.current?.contains(target)) return;
setShowNodePicker(false);
setNodePickerPosition(null);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showNodePicker]);
useEffect(() => {
if (!showAgentPicker) return;
@@ -384,6 +403,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setSelectedAgentId(null);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
@@ -545,6 +566,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsRefineMenuOpen(false);
return;
}
if (showNodePicker) {
setShowNodePicker(false);
setNodePickerPosition(null);
return;
}
if (showAgentPicker) {
setShowAgentPicker(false);
setAgentPickerPosition(null);
@@ -574,6 +600,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
isExpanded,
showDeps,
showAgentPicker,
showNodePicker,
isModelMenuOpen,
activeModelSubmenu,
isRefineMenuOpen,
@@ -838,6 +865,65 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, [getEffectiveViewport]);
const updateNodePickerPosition = useCallback(() => {
const trigger = nodePickerRef.current?.querySelector("button") as HTMLButtonElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const horizontalPadding = 16;
const verticalPadding = 16;
const gap = 4;
const isMobile = viewportWidth <= 768;
const preferredHeight = isMobile
? Math.min(viewportHeight * 0.6, 320)
: Math.min(viewportHeight * 0.5, 320);
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 280)
: Math.max(rect.width, 240);
const width = Math.min(
preferredWidth,
Math.max(viewportWidth - horizontalPadding * 2, 200),
);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
const maxHeight = Math.max(
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
160,
);
const left = Math.min(
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - maxHeight,
);
setNodePickerPosition({
top,
left,
width,
maxHeight,
});
}, [getEffectiveViewport]);
// Keep model menu portal anchored during scroll/resize
useEffect(() => {
if (!isModelMenuOpen) return;
@@ -938,6 +1024,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
};
}, [showAgentPicker, updateAgentPickerPosition]);
// Keep node picker portal anchored during scroll/resize
useEffect(() => {
if (!showNodePicker) return;
const handleReposition = () => updateNodePickerPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [showNodePicker, updateNodePickerPosition]);
const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningProvider(next.provider);
@@ -1107,6 +1218,7 @@ 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;
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
@@ -1272,6 +1384,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
// Position the dropdown before rendering
updateDepDropdownPosition();
} else {
@@ -1362,6 +1477,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
onClick={() => {
setShowDeps(false);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setActiveModelSubmenu(null);
setIsModelMenuOpen(true);
updateModelMenuPosition();
@@ -1371,35 +1489,86 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
{modelMenuLabel}
</button>
<label className="quick-entry-node-wrap" htmlFor="quick-entry-node-select">
<span className="visually-hidden">Execution Node Override</span>
<select
id="quick-entry-node-select"
className="select quick-entry-node-select"
data-testid="quick-entry-node-select"
value={nodeId ?? ""}
onChange={(e) => {
const nextNodeId = e.target.value;
setNodeId(nextNodeId || undefined);
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
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);
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
updateNodePickerPosition();
} else {
setNodePickerPosition(null);
}
return next;
});
}}
>
<option value="">Execution Node: Project default / local</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{`${node.name} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<Server size={12} style={{ verticalAlign: "middle" }} />
{` ${selectedNode?.name ?? "Node"}`}
{selectedNode && (
<span className="quick-entry-node-status">
<NodeHealthDot status={selectedNode.status} showLabel />
</span>
);
})()}
</label>
)}
</button>
</div>
{showNodePicker && portalRoot && nodePickerPosition && createPortal(
<div
ref={nodePickerPortalRef}
className="dep-dropdown node-picker-dropdown node-picker-dropdown--portal"
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${nodePickerPosition.top}px`,
left: `${nodePickerPosition.left}px`,
width: `${nodePickerPosition.width}px`,
maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined,
overflowY: nodePickerPosition.maxHeight ? "auto" : undefined,
}}
>
<div className="dep-dropdown-search-header">Select execution node</div>
<div
className={`dep-dropdown-item node-picker-item${nodeId == null ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setNodeId(undefined);
setShowNodePicker(false);
setNodePickerPosition(null);
}}
>
<span className="node-picker-item-name">Project default / local</span>
</div>
{nodes.map((node) => (
<div
key={node.id}
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setNodeId(node.id);
setShowNodePicker(false);
setNodePickerPosition(null);
}}
>
<span className="quick-entry-node-status">
<NodeHealthDot status={node.status} />
</span>
<span className="node-picker-item-name">{node.name}</span>
<span className="node-picker-item-status">{getNodeStatusLabel(node.status)}</span>
</div>
))}
</div>,
portalRoot,
)}
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
@@ -1410,6 +1579,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setShowAgentPicker(false);
setAgentPickerPosition(null);
} else {
setShowNodePicker(false);
setNodePickerPosition(null);
void loadAgents();
}
}}

View File

@@ -22,6 +22,7 @@ vi.mock("lucide-react", () => ({
ChevronDown: () => null,
ChevronUp: () => null,
Bot: () => null,
Server: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
@@ -1398,19 +1399,68 @@ describe("InlineCreateCard button visibility when collapsed", () => {
describe("InlineCreateCard node override", () => {
it("opens node picker from button", () => {
renderCard();
expandCard();
fireEvent.click(screen.getByTestId("inline-create-node-button"));
expect(screen.getByText("Select execution node")).toBeInTheDocument();
});
it("closes node picker on outside click", () => {
renderCard();
expandCard();
fireEvent.click(screen.getByTestId("inline-create-node-button"));
expect(screen.getByText("Select execution node")).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByText("Select execution node")).not.toBeInTheDocument();
});
it("includes nodeId in payload when execution node override is selected", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task);
renderCard([], { onSubmit });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Run on node" } });
expandCard();
fireEvent.change(screen.getByTestId("inline-create-node-select"), { target: { value: "node-1" } });
fireEvent.click(screen.getByTestId("inline-create-node-button"));
fireEvent.click(screen.getByRole("button", { name: /Node One/i }));
fireEvent.click(screen.getByTestId("save-button"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-1" }));
});
});
it("clears node override when project default is selected", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-778" } as Task);
renderCard([], { onSubmit });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Run locally" } });
expandCard();
fireEvent.click(screen.getByTestId("inline-create-node-button"));
fireEvent.click(screen.getByRole("button", { name: /Node Two/i }));
fireEvent.click(screen.getByTestId("inline-create-node-button"));
fireEvent.click(screen.getByRole("button", { name: /Project default \/ local/i }));
fireEvent.click(screen.getByTestId("save-button"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined }));
});
});
it("shows selected node name on the node button", () => {
renderCard();
expandCard();
fireEvent.click(screen.getByTestId("inline-create-node-button"));
fireEvent.click(screen.getByRole("button", { name: /Node Two/i }));
expect(screen.getByTestId("inline-create-node-button")).toHaveTextContent("Node Two");
});
});
});

View File

@@ -132,6 +132,7 @@ vi.mock("lucide-react", () => ({
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
@@ -3021,11 +3022,65 @@ describe("QuickEntryBox", () => {
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route this task" } });
expandQuickEntry();
fireEvent.change(screen.getByTestId("quick-entry-node-select"), { target: { value: "node-2" } });
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
fireEvent.click(screen.getByText("Node Two"));
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-2" }));
});
});
it("opens node picker menu from the node button", () => {
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
expect(screen.getByText("Select execution node")).toBeInTheDocument();
const nodePicker = document.body.querySelector(".node-picker-dropdown");
expect(nodePicker?.classList.contains("node-picker-dropdown--portal")).toBe(true);
});
it("closes node picker when clicking outside", () => {
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
expect(screen.getByText("Select execution node")).toBeInTheDocument();
const outside = document.createElement("div");
document.body.appendChild(outside);
fireEvent.mouseDown(outside);
expect(screen.queryByText("Select execution node")).not.toBeInTheDocument();
});
it("clears node override when selecting project default / local", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
renderQuickEntryBox({ onCreate });
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Default node route" } });
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
fireEvent.click(screen.getByText("Node One"));
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
fireEvent.click(screen.getByText("Project default / local"));
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined }));
});
});
it("shows selected node name on the node button", () => {
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
fireEvent.click(screen.getByText("Node Two"));
expect(screen.getByTestId("quick-entry-node-button")).toHaveTextContent("Node Two");
});
});