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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user