feat(FN-1088): move quick entry advanced controls into actions menu

- Replace direct Deps/Models/Save controls with a single actions trigger in QuickEntryBox
- Add portaled actions dropdown behavior, including outside-click and Escape handling plus menu repositioning
- Add actions badge styling/count to reflect selected dependencies and model overrides
- Update QuickEntryBox/ListView tests and AGENTS.md docs to match the new actions-menu interaction flow
This commit is contained in:
gsxdsm
2026-04-07 19:20:59 -07:00
parent e952dd5760
commit af4691f17b
5 changed files with 578 additions and 407 deletions

View File

@@ -4,7 +4,7 @@ import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ModelInfo, RefinementType } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight } from "lucide-react";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
const STORAGE_KEY = "kb-quick-entry-text";
@@ -90,6 +90,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
const [actionsMenuPosition, setActionsMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
@@ -98,6 +100,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const actionsMenuPortalRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
@@ -183,13 +187,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
const hasPlanningOverride = Boolean(planningProvider && planningModelId);
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((p) => p.id === selectedPresetId);
const handlePresetChange = useCallback((presetId: string | undefined) => {
setSelectedPresetId(presetId);
}, []);
const modelMenuLabel = selectedPresetId
? settings?.modelPresets?.find((p) => p.id === selectedPresetId)?.name ?? "Models"
: selectedModelCount > 0
? `${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: "Models";
const actionSelectionCount = dependencies.length + selectedModelCount;
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
@@ -279,6 +282,27 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isRefineMenuOpen]);
// Close actions menu when clicking outside
useEffect(() => {
if (!isActionsMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
const clickedInsideTrigger = actionsMenuRef.current?.contains(target);
const clickedInsidePortal = actionsMenuPortalRef.current?.contains(target);
// Also check for clicks inside CustomModelDropdown's portaled dropdown
const clickedInsideCombobox = (target instanceof Element) && (target.closest?.(".model-combobox-dropdown--portal") != null);
if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) {
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isActionsMenuOpen]);
// Close model menu when clicking outside
useEffect(() => {
if (!isModelMenuOpen) return;
@@ -311,7 +335,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setPlanningModelId(undefined);
setSelectedPresetId(undefined);
setShowDeps(false);
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setIsRefineMenuOpen(false);
setIsRefining(false);
@@ -385,6 +412,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
// Close actions menu first if open
if (isActionsMenuOpen) {
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
return;
}
// Close model submenu first if open
if (activeModelSubmenu) {
setActiveModelSubmenu(null);
@@ -393,6 +426,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Close model menu if open
if (isModelMenuOpen) {
setIsModelMenuOpen(false);
setModelMenuPosition(null);
return;
}
// Close dependency or refine popover if open
@@ -424,6 +458,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
description,
isExpanded,
showDeps,
isActionsMenuOpen,
isModelMenuOpen,
activeModelSubmenu,
isRefineMenuOpen,
@@ -452,19 +487,49 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
);
}, []);
const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => {
const updateActionsMenuPosition = useCallback(() => {
const trigger = actionsMenuRef.current?.querySelector(".quick-entry-actions-trigger") as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth <= 640;
if (isMobile) {
const mobileWidth = Math.min(viewportWidth - 32, 320);
const left = Math.max((viewportWidth - mobileWidth) / 2, 16);
setActionsMenuPosition({
top: rect.bottom + 4,
left,
width: mobileWidth,
});
} else {
setActionsMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 200),
});
}
}, []);
const toggleActionsMenu = useCallback(() => {
setIsActionsMenuOpen((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
updateActionsMenuPosition();
} else {
setActionsMenuPosition(null);
}
return next;
});
}, []);
}, [updateActionsMenuPosition]);
const updateModelMenuPosition = useCallback(() => {
const trigger = modelMenuRef.current?.querySelector(".quick-entry-model-trigger") as HTMLElement | null;
const trigger = modelMenuRef.current?.querySelector(".quick-entry-actions-trigger") as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
@@ -489,21 +554,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, []);
const toggleModelMenu = useCallback(() => {
setIsModelMenuOpen((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
// Compute position synchronously so the portal renders on first paint
updateModelMenuPosition();
} else {
setActiveModelSubmenu(null);
setModelMenuPosition(null);
}
return next;
});
}, [updateModelMenuPosition]);
const updateRefineMenuPosition = useCallback(() => {
const trigger = refineMenuRef.current?.querySelector(".refine-button") as HTMLElement | null;
if (!trigger) return;
@@ -522,6 +572,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, []);
// Keep actions menu portal anchored during scroll/resize
useEffect(() => {
if (!isActionsMenuOpen) return;
const handleReposition = () => updateActionsMenuPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isActionsMenuOpen, updateActionsMenuPosition]);
// Keep model menu portal anchored during scroll/resize
useEffect(() => {
if (!isModelMenuOpen) return;
@@ -843,17 +908,96 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div>
)}
<div className="quick-entry-controls-left">
{/* Deps button */}
<div className="dep-trigger-wrap">
<div
className="quick-entry-actions-wrap dep-trigger-wrap"
ref={(node) => {
actionsMenuRef.current = node;
modelMenuRef.current = node;
}}
>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
data-testid="quick-entry-deps-button"
className="btn btn-sm quick-entry-actions-trigger"
onClick={toggleActionsMenu}
aria-expanded={isActionsMenuOpen}
aria-haspopup="menu"
aria-label="Advanced creation options"
data-testid="quick-entry-actions-trigger"
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
<MoreHorizontal size={12} style={{ verticalAlign: "middle" }} />
{actionSelectionCount > 0 && (
<span className="quick-entry-actions-badge" data-testid="quick-entry-actions-badge">
{actionSelectionCount}
</span>
)}
</button>
{isActionsMenuOpen && portalRoot && actionsMenuPosition && createPortal(
<div
ref={actionsMenuPortalRef}
className="actions-dropdown actions-dropdown--portal"
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${actionsMenuPosition.top}px`,
left: `${actionsMenuPosition.left}px`,
width: `${actionsMenuPosition.width}px`,
}}
>
<div className="actions-dropdown-items">
<button
type="button"
className="btn btn-sm dep-trigger"
data-testid="quick-entry-actions-deps"
onClick={() => {
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowDeps(true);
}}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
</button>
<button
type="button"
className="btn btn-sm"
data-testid="quick-entry-actions-models"
onClick={() => {
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
setShowDeps(false);
setActiveModelSubmenu(null);
setIsModelMenuOpen(true);
updateModelMenuPosition();
}}
>
<Brain size={12} style={{ verticalAlign: "middle" }} />
{modelMenuLabel}
</button>
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={() => {
handleSaveClick();
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
}}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="quick-entry-actions-save"
title="Create task"
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Save
</button>
</div>
</div>,
portalRoot,
)}
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
@@ -900,162 +1044,128 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
})()}
</div>
{/* Models button */}
<div className="quick-entry-model-wrap" ref={modelMenuRef}>
<button
type="button"
className="btn btn-sm quick-entry-model-trigger"
onClick={toggleModelMenu}
aria-expanded={isModelMenuOpen}
aria-haspopup="menu"
data-testid="quick-entry-models-button"
>
<Brain size={12} style={{ verticalAlign: "middle" }} />
{selectedPreset
? ` ${selectedPreset.name}`
: selectedModelCount > 0
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
</button>
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
<div
ref={modelMenuPortalRef}
className="model-nested-menu model-nested-menu--portal"
onMouseDown={(e) => e.preventDefault()}
data-testid="model-nested-menu"
style={{
position: "fixed",
top: `${modelMenuPosition.top}px`,
left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`,
}}
>
{activeModelSubmenu === null ? (
// Top-level menu with Plan/Executor/Validator choices
<div className="model-menu-items">
<button
type="button"
className={`model-menu-item ${hasPlanningOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("plan")}
data-testid="model-menu-plan"
>
<span className="model-menu-item-label">
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Plan
</span>
<span className="model-menu-item-value">
{hasPlanningOverride
? getModelBadgeLabel(planningProvider, planningModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
<button
type="button"
className={`model-menu-item ${hasExecutorOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("executor")}
data-testid="model-menu-executor"
>
<span className="model-menu-item-label">
<Sparkles size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Executor
</span>
<span className="model-menu-item-value">
{hasExecutorOverride
? getModelBadgeLabel(executorProvider, executorModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
<button
type="button"
className={`model-menu-item ${hasValidatorOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("validator")}
data-testid="model-menu-validator"
>
<span className="model-menu-item-label">
<Brain size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Validator
</span>
<span className="model-menu-item-value">
{hasValidatorOverride
? getModelBadgeLabel(validatorProvider, validatorModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
</div>
) : (
// Submenu with CustomModelDropdown for the selected target
<div className="model-submenu">
<button
type="button"
className="model-submenu-back"
onClick={() => setActiveModelSubmenu(null)}
data-testid="model-submenu-back"
>
<ChevronDown size={12} style={{ transform: "rotate(90deg)", marginRight: 4 }} />
Back
</button>
<div className="model-submenu-header">
{activeModelSubmenu === "plan" && "Plan Model"}
{activeModelSubmenu === "executor" && "Executor Model"}
{activeModelSubmenu === "validator" && "Validator Model"}
</div>
<CustomModelDropdown
models={loadedModels}
value={
activeModelSubmenu === "plan"
? planningSelectionValue
: activeModelSubmenu === "executor"
? executorSelectionValue
: validatorSelectionValue
}
onChange={
activeModelSubmenu === "plan"
? handlePlanningModelChange
: activeModelSubmenu === "executor"
? handleExecutorChange
: handleValidatorChange
}
placeholder="Using default"
disabled={modelsLoading}
id={`model-${activeModelSubmenu}-select`}
label={`${activeModelSubmenu} model`}
favoriteProviders={effectiveFavoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={effectiveFavoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
{modelsError && (
<div className="model-submenu-error">
<span>{modelsError}</span>
<button type="button" className="btn btn-sm" onClick={loadModels}>
Retry
</button>
</div>
)}
</div>
)}
</div>,
portalRoot,
)}
</div>
{/* Save button */}
{!isSubmitting && (
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSaveClick}
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
<div
ref={modelMenuPortalRef}
className="model-nested-menu model-nested-menu--portal"
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="save-button"
title="Create task"
data-testid="model-nested-menu"
style={{
position: "fixed",
top: `${modelMenuPosition.top}px`,
left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`,
}}
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Save
</button>
{activeModelSubmenu === null ? (
// Top-level menu with Plan/Executor/Validator choices
<div className="model-menu-items">
<button
type="button"
className={`model-menu-item ${hasPlanningOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("plan")}
data-testid="model-menu-plan"
>
<span className="model-menu-item-label">
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Plan
</span>
<span className="model-menu-item-value">
{hasPlanningOverride
? getModelBadgeLabel(planningProvider, planningModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
<button
type="button"
className={`model-menu-item ${hasExecutorOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("executor")}
data-testid="model-menu-executor"
>
<span className="model-menu-item-label">
<Sparkles size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Executor
</span>
<span className="model-menu-item-value">
{hasExecutorOverride
? getModelBadgeLabel(executorProvider, executorModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
<button
type="button"
className={`model-menu-item ${hasValidatorOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("validator")}
data-testid="model-menu-validator"
>
<span className="model-menu-item-label">
<Brain size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
Validator
</span>
<span className="model-menu-item-value">
{hasValidatorOverride
? getModelBadgeLabel(validatorProvider, validatorModelId)
: "Using default"}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
</div>
) : (
// Submenu with CustomModelDropdown for the selected target
<div className="model-submenu">
<button
type="button"
className="model-submenu-back"
onClick={() => setActiveModelSubmenu(null)}
data-testid="model-submenu-back"
>
<ChevronDown size={12} style={{ transform: "rotate(90deg)", marginRight: 4 }} />
Back
</button>
<div className="model-submenu-header">
{activeModelSubmenu === "plan" && "Plan Model"}
{activeModelSubmenu === "executor" && "Executor Model"}
{activeModelSubmenu === "validator" && "Validator Model"}
</div>
<CustomModelDropdown
models={loadedModels}
value={
activeModelSubmenu === "plan"
? planningSelectionValue
: activeModelSubmenu === "executor"
? executorSelectionValue
: validatorSelectionValue
}
onChange={
activeModelSubmenu === "plan"
? handlePlanningModelChange
: activeModelSubmenu === "executor"
? handleExecutorChange
: handleValidatorChange
}
placeholder="Using default"
disabled={modelsLoading}
id={`model-${activeModelSubmenu}-select`}
label={`${activeModelSubmenu} model`}
favoriteProviders={effectiveFavoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={effectiveFavoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
{modelsError && (
<div className="model-submenu-error">
<span>{modelsError}</span>
<button type="button" className="btn btn-sm" onClick={loadModels}>
Retry
</button>
</div>
)}
</div>
)}
</div>,
portalRoot,
)}
</div>
<div className="quick-entry-hint">