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:
@@ -61,6 +61,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
ChevronUp: () => null,
|
ChevronUp: () => null,
|
||||||
ChevronRight: () => null,
|
ChevronRight: () => null,
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
|
Server: () => null,
|
||||||
Maximize2: () => null,
|
Maximize2: () => null,
|
||||||
Minimize2: () => null,
|
Minimize2: () => null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -215,14 +215,33 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-create-node-wrap {
|
.node-trigger-wrap {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-create-node-select {
|
.node-picker-dropdown {
|
||||||
max-width: calc(var(--space-2xl) * 8);
|
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 {
|
.inline-create-node-status {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "./InlineCreateCard.css";
|
import "./InlineCreateCard.css";
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -98,6 +98,7 @@ export function InlineCreateCard({
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||||||
|
const [showNodePicker, setShowNodePicker] = useState(false);
|
||||||
const [agentsLoading, setAgentsLoading] = useState(false);
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
||||||
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
|
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
|
||||||
const [showPresets, setShowPresets] = useState(false);
|
const [showPresets, setShowPresets] = useState(false);
|
||||||
@@ -127,6 +128,7 @@ export function InlineCreateCard({
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nodePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
|
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
|
||||||
@@ -188,6 +190,19 @@ export function InlineCreateCard({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showAgentPicker]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -374,6 +389,7 @@ export function InlineCreateCard({
|
|||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
setNodeId(undefined);
|
setNodeId(undefined);
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setIsModelModalOpen(false);
|
setIsModelModalOpen(false);
|
||||||
setShowPresets(false);
|
setShowPresets(false);
|
||||||
@@ -422,8 +438,9 @@ export function InlineCreateCard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Close dropdowns first if open
|
// Close dropdowns first if open
|
||||||
if (showDeps || showAgentPicker || isModelModalOpen || showPresets) {
|
if (showDeps || showNodePicker || showAgentPicker || isModelModalOpen || showPresets) {
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setIsModelModalOpen(false);
|
setIsModelModalOpen(false);
|
||||||
setShowPresets(false);
|
setShowPresets(false);
|
||||||
@@ -457,6 +474,7 @@ export function InlineCreateCard({
|
|||||||
description,
|
description,
|
||||||
isDescriptionExpanded,
|
isDescriptionExpanded,
|
||||||
showDeps,
|
showDeps,
|
||||||
|
showNodePicker,
|
||||||
showAgentPicker,
|
showAgentPicker,
|
||||||
isModelModalOpen,
|
isModelModalOpen,
|
||||||
showPresets,
|
showPresets,
|
||||||
@@ -475,6 +493,7 @@ export function InlineCreateCard({
|
|||||||
const next = !prev;
|
const next = !prev;
|
||||||
if (next) {
|
if (next) {
|
||||||
setIsModelModalOpen(false);
|
setIsModelModalOpen(false);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -484,11 +503,13 @@ export function InlineCreateCard({
|
|||||||
const toggleModelsDropdown = useCallback(() => {
|
const toggleModelsDropdown = useCallback(() => {
|
||||||
setIsModelModalOpen(true);
|
setIsModelModalOpen(true);
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAgents = useCallback(async () => {
|
const loadAgents = useCallback(async () => {
|
||||||
if (agents.length > 0) {
|
if (agents.length > 0) {
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(true);
|
setShowAgentPicker(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -497,6 +518,7 @@ export function InlineCreateCard({
|
|||||||
try {
|
try {
|
||||||
const result = await fetchAgents(undefined, projectId);
|
const result = await fetchAgents(undefined, projectId);
|
||||||
setAgents(result);
|
setAgents(result);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(true);
|
setShowAgentPicker(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = getErrorMessage(err);
|
const msg = getErrorMessage(err);
|
||||||
@@ -509,6 +531,7 @@ export function InlineCreateCard({
|
|||||||
|
|
||||||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||||||
|
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||||||
|
|
||||||
const handleExecutorChange = useCallback((value: string) => {
|
const handleExecutorChange = useCallback((value: string) => {
|
||||||
const next = parseModelSelection(value);
|
const next = parseModelSelection(value);
|
||||||
@@ -835,36 +858,66 @@ export function InlineCreateCard({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="inline-create-node-wrap" htmlFor="inline-create-node-select">
|
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||||
<span className="visually-hidden">Execution Node Override</span>
|
<button
|
||||||
<select
|
type="button"
|
||||||
id="inline-create-node-select"
|
className="btn btn-sm dep-trigger"
|
||||||
className="select inline-create-node-select"
|
data-testid="inline-create-node-button"
|
||||||
data-testid="inline-create-node-select"
|
onClick={() => {
|
||||||
value={nodeId ?? ""}
|
setShowNodePicker((prev) => {
|
||||||
onChange={(e) => {
|
const next = !prev;
|
||||||
const nextNodeId = e.target.value;
|
if (next) {
|
||||||
setNodeId(nextNodeId || undefined);
|
setShowDeps(false);
|
||||||
|
setShowAgentPicker(false);
|
||||||
|
setIsModelModalOpen(false);
|
||||||
|
setShowPresets(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Execution Node: Project default / local</option>
|
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||||
{nodes.map((node) => (
|
{selectedNode ? ` ${selectedNode.name}` : " Node"}
|
||||||
<option key={node.id} value={node.id}>
|
{selectedNode && (
|
||||||
{`${node.name} (${getNodeStatusLabel(node.status)})`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{(() => {
|
|
||||||
const selectedNode = nodes.find((node) => node.id === nodeId);
|
|
||||||
if (!selectedNode) return null;
|
|
||||||
return (
|
|
||||||
<span className={`inline-create-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
<span className={`inline-create-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||||
<span className="inline-create-node-status__dot" aria-hidden="true" />
|
<span className="inline-create-node-status__dot" aria-hidden="true" />
|
||||||
{getNodeStatusLabel(selectedNode.status)}
|
{getNodeStatusLabel(selectedNode.status)}
|
||||||
</span>
|
</span>
|
||||||
);
|
)}
|
||||||
})()}
|
</button>
|
||||||
</label>
|
{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}>
|
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||||
<button
|
<button
|
||||||
@@ -957,6 +1010,7 @@ export function InlineCreateCard({
|
|||||||
const next = !prev;
|
const next = !prev;
|
||||||
if (next) {
|
if (next) {
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
|
setShowNodePicker(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setIsModelModalOpen(false);
|
setIsModelModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,14 +117,39 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-entry-node-wrap {
|
.node-trigger-wrap {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
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;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-entry-node-select {
|
.node-picker-item-name {
|
||||||
max-width: calc(var(--space-2xl) * 8);
|
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 {
|
.quick-entry-node-status {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
|||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import type { ModelInfo, RefinementType, Agent } from "../api";
|
import type { ModelInfo, RefinementType, Agent } from "../api";
|
||||||
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } 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 { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
@@ -119,6 +119,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
|
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
|
||||||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||||||
|
const [showNodePicker, setShowNodePicker] = useState(false);
|
||||||
const [agentsLoading, setAgentsLoading] = useState(false);
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
||||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||||
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
|
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 modelMenuPortalRef = useRef<HTMLDivElement>(null);
|
||||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||||
const agentPickerPortalRef = 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 [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);
|
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||||||
// Dependency dropdown portal refs and state
|
// Dependency dropdown portal refs and state
|
||||||
const depTriggerRef = useRef<HTMLButtonElement>(null);
|
const depTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -356,6 +360,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [isModelMenuOpen]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!showAgentPicker) return;
|
if (!showAgentPicker) return;
|
||||||
|
|
||||||
@@ -384,6 +403,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setAgentPickerPosition(null);
|
setAgentPickerPosition(null);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
setExecutorProvider(undefined);
|
setExecutorProvider(undefined);
|
||||||
setExecutorModelId(undefined);
|
setExecutorModelId(undefined);
|
||||||
setValidatorProvider(undefined);
|
setValidatorProvider(undefined);
|
||||||
@@ -545,6 +566,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setIsRefineMenuOpen(false);
|
setIsRefineMenuOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (showNodePicker) {
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (showAgentPicker) {
|
if (showAgentPicker) {
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setAgentPickerPosition(null);
|
setAgentPickerPosition(null);
|
||||||
@@ -574,6 +600,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
showDeps,
|
showDeps,
|
||||||
showAgentPicker,
|
showAgentPicker,
|
||||||
|
showNodePicker,
|
||||||
isModelMenuOpen,
|
isModelMenuOpen,
|
||||||
activeModelSubmenu,
|
activeModelSubmenu,
|
||||||
isRefineMenuOpen,
|
isRefineMenuOpen,
|
||||||
@@ -838,6 +865,65 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
});
|
});
|
||||||
}, [getEffectiveViewport]);
|
}, [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
|
// Keep model menu portal anchored during scroll/resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isModelMenuOpen) return;
|
if (!isModelMenuOpen) return;
|
||||||
@@ -938,6 +1024,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
};
|
};
|
||||||
}, [showAgentPicker, updateAgentPickerPosition]);
|
}, [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 handlePlanningModelChange = useCallback((value: string) => {
|
||||||
const next = parseModelSelection(value);
|
const next = parseModelSelection(value);
|
||||||
setPlanningProvider(next.provider);
|
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 selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
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
|
// Show expanded controls based on disclosure state (user preference), not textarea focus
|
||||||
const showExpandedControls = isDisclosureExpanded;
|
const showExpandedControls = isDisclosureExpanded;
|
||||||
@@ -1272,6 +1384,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setModelMenuPosition(null);
|
setModelMenuPosition(null);
|
||||||
setActiveModelSubmenu(null);
|
setActiveModelSubmenu(null);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
|
setAgentPickerPosition(null);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
// Position the dropdown before rendering
|
// Position the dropdown before rendering
|
||||||
updateDepDropdownPosition();
|
updateDepDropdownPosition();
|
||||||
} else {
|
} else {
|
||||||
@@ -1362,6 +1477,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
|
setAgentPickerPosition(null);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
setActiveModelSubmenu(null);
|
setActiveModelSubmenu(null);
|
||||||
setIsModelMenuOpen(true);
|
setIsModelMenuOpen(true);
|
||||||
updateModelMenuPosition();
|
updateModelMenuPosition();
|
||||||
@@ -1371,35 +1489,86 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
{modelMenuLabel}
|
{modelMenuLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<label className="quick-entry-node-wrap" htmlFor="quick-entry-node-select">
|
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||||
<span className="visually-hidden">Execution Node Override</span>
|
<button
|
||||||
<select
|
type="button"
|
||||||
id="quick-entry-node-select"
|
className="btn btn-sm dep-trigger"
|
||||||
className="select quick-entry-node-select"
|
data-testid="quick-entry-node-button"
|
||||||
data-testid="quick-entry-node-select"
|
onClick={() => {
|
||||||
value={nodeId ?? ""}
|
setShowDeps(false);
|
||||||
onChange={(e) => {
|
setShowAgentPicker(false);
|
||||||
const nextNodeId = e.target.value;
|
setAgentPickerPosition(null);
|
||||||
setNodeId(nextNodeId || undefined);
|
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>
|
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||||
{nodes.map((node) => (
|
{` ${selectedNode?.name ?? "Node"}`}
|
||||||
<option key={node.id} value={node.id}>
|
{selectedNode && (
|
||||||
{`${node.name} (${getNodeStatusLabel(node.status)})`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{(() => {
|
|
||||||
const selectedNode = nodes.find((node) => node.id === nodeId);
|
|
||||||
if (!selectedNode) return null;
|
|
||||||
return (
|
|
||||||
<span className="quick-entry-node-status">
|
<span className="quick-entry-node-status">
|
||||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||||
</span>
|
</span>
|
||||||
);
|
)}
|
||||||
})()}
|
</button>
|
||||||
</label>
|
</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}>
|
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||||
<button
|
<button
|
||||||
@@ -1410,6 +1579,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setAgentPickerPosition(null);
|
setAgentPickerPosition(null);
|
||||||
} else {
|
} else {
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
void loadAgents();
|
void loadAgents();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
ChevronDown: () => null,
|
ChevronDown: () => null,
|
||||||
ChevronUp: () => null,
|
ChevronUp: () => null,
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
|
Server: () => null,
|
||||||
Maximize2: () => null,
|
Maximize2: () => null,
|
||||||
Minimize2: () => null,
|
Minimize2: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -1398,19 +1399,68 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
|||||||
|
|
||||||
|
|
||||||
describe("InlineCreateCard node override", () => {
|
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 () => {
|
it("includes nodeId in payload when execution node override is selected", async () => {
|
||||||
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task);
|
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task);
|
||||||
renderCard([], { onSubmit });
|
renderCard([], { onSubmit });
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Run on node" } });
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Run on node" } });
|
||||||
expandCard();
|
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"));
|
fireEvent.click(screen.getByTestId("save-button"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-1" }));
|
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,
|
ChevronUp: () => null,
|
||||||
ChevronRight: () => null,
|
ChevronRight: () => null,
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
|
Server: () => null,
|
||||||
Maximize2: () => null,
|
Maximize2: () => null,
|
||||||
Minimize2: () => null,
|
Minimize2: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -3021,11 +3022,65 @@ describe("QuickEntryBox", () => {
|
|||||||
|
|
||||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route this task" } });
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route this task" } });
|
||||||
expandQuickEntry();
|
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();
|
clickSave();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-2" }));
|
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