feat(FN-3186): add priority control to quick-entry box

This merge adds a quick-entry priority control to the QuickEntryBox component, polishes GitHub source issue summary styling in the TaskDetailModal, and documents layered agent memory access in the agents reference. The changes include both the component implementation with 186 lines of new logic and

Fusion-Task-Id: FN-3186
This commit is contained in:
Fusion
2026-05-02 02:50:46 -07:00
committed by gsxdsm
parent 6b75b3ac8e
commit 99b8183f01
5 changed files with 274 additions and 4 deletions

View File

@@ -62,6 +62,7 @@ vi.mock("lucide-react", () => ({
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
Flag: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));

View File

@@ -117,11 +117,24 @@
position: relative;
}
.node-trigger-wrap {
.node-trigger-wrap,
.priority-trigger-wrap {
position: relative;
display: inline-flex;
}
.priority-picker-dropdown {
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
background: var(--surface);
z-index: 1000;
}
.priority-picker-dropdown--portal {
position: fixed;
}
.node-picker-dropdown {
border: 1px solid var(--border);
border-radius: var(--radius-md);

View File

@@ -2,11 +2,11 @@ import "./QuickEntryBox.css";
import { useState, useCallback, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
import type { Task, TaskCreateInput, Settings, TaskPriority } 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, Server } from "lucide-react";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
@@ -120,6 +120,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [showNodePicker, setShowNodePicker] = useState(false);
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
@@ -135,8 +136,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const agentPickerPortalRef = useRef<HTMLDivElement>(null);
const nodePickerRef = useRef<HTMLDivElement>(null);
const nodePickerPortalRef = useRef<HTMLDivElement>(null);
const priorityPickerRef = useRef<HTMLDivElement>(null);
const priorityPickerPortalRef = 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 [priorityPickerPosition, setPriorityPickerPosition] = 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);
@@ -153,6 +157,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [isFastMode, setIsFastMode] = useState(false);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const { nodes } = useNodes();
@@ -391,6 +396,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
useEffect(() => {
if (!showPriorityPicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (priorityPickerRef.current?.contains(target)) return;
if (priorityPickerPortalRef.current?.contains(target)) return;
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showPriorityPicker]);
const resetForm = useCallback(() => {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
@@ -405,6 +425,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
@@ -413,6 +435,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setPlanningModelId(undefined);
setSelectedPresetId(undefined);
setIsFastMode(false);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setShowDeps(false);
setIsModelMenuOpen(false);
@@ -485,6 +508,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
...(isFastMode ? { executionMode: "fast" } : {}),
priority,
nodeId,
});
if (createdTask && pendingImages.length > 0) {
@@ -531,6 +555,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
addToast,
resetForm,
isFastMode,
priority,
nodeId,
]);
@@ -571,6 +596,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setNodePickerPosition(null);
return;
}
if (showPriorityPicker) {
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
return;
}
if (showAgentPicker) {
setShowAgentPicker(false);
setAgentPickerPosition(null);
@@ -604,6 +634,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
isModelMenuOpen,
activeModelSubmenu,
isRefineMenuOpen,
showPriorityPicker,
projectId,
setIsDisclosureExpanded,
],
@@ -925,6 +956,55 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, [getEffectiveViewport]);
const updatePriorityPickerPosition = useCallback(() => {
const trigger = priorityPickerRef.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 preferredHeight = 220;
const width = Math.min(
Math.max(rect.width, 200),
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,
);
setPriorityPickerPosition({
top,
left,
width,
maxHeight,
});
}, [getEffectiveViewport]);
// Keep model menu portal anchored during scroll/resize
useEffect(() => {
if (!isModelMenuOpen) return;
@@ -1050,6 +1130,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
};
}, [showNodePicker, updateNodePickerPosition]);
// Keep priority picker portal anchored during scroll/resize
useEffect(() => {
if (!showPriorityPicker) return;
const handleReposition = () => updatePriorityPickerPosition();
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);
}
};
}, [showPriorityPicker, updatePriorityPickerPosition]);
const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningProvider(next.provider);
@@ -1388,6 +1493,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
// Position the dropdown before rendering
updateDepDropdownPosition();
} else {
@@ -1481,6 +1588,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setActiveModelSubmenu(null);
setIsModelMenuOpen(true);
updateModelMenuPosition();
@@ -1502,6 +1611,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
@@ -1582,6 +1693,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} else {
setShowNodePicker(false);
setNodePickerPosition(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
void loadAgents();
}
}}
@@ -1643,6 +1756,73 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
portalRoot,
)}
<div className="priority-trigger-wrap" ref={priorityPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
data-testid="quick-entry-priority-button"
onClick={() => {
setShowDeps(false);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowPriorityPicker((prev) => {
const next = !prev;
if (next) {
updatePriorityPickerPosition();
} else {
setPriorityPickerPosition(null);
}
return next;
});
}}
>
<Flag size={12} style={{ verticalAlign: "middle" }} />
{` ${priority[0].toUpperCase()}${priority.slice(1)}`}
</button>
</div>
{showPriorityPicker && portalRoot && priorityPickerPosition && createPortal(
<div
ref={priorityPickerPortalRef}
className="dep-dropdown priority-picker-dropdown priority-picker-dropdown--portal"
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${priorityPickerPosition.top}px`,
left: `${priorityPickerPosition.left}px`,
width: `${priorityPickerPosition.width}px`,
maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined,
overflowY: priorityPickerPosition.maxHeight ? "auto" : undefined,
}}
>
<div className="dep-dropdown-search-header">Select priority</div>
{TASK_PRIORITIES.map((taskPriority) => {
const label = `${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}`;
return (
<div
key={taskPriority}
className={`dep-dropdown-item${priority === taskPriority ? " selected" : ""}`}
data-testid={`quick-entry-priority-option-${taskPriority}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setPriority(taskPriority);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
}}
>
<span className="dep-dropdown-title">{label}</span>
</div>
);
})}
</div>,
portalRoot,
)}
<button
type="button"
className={`btn btn-sm ${isFastMode ? "btn-primary" : ""}`}

View File

@@ -133,6 +133,7 @@ vi.mock("lucide-react", () => ({
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
Flag: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
@@ -216,6 +217,10 @@ function clickSave() {
fireEvent.click(screen.getByTestId("quick-entry-save"));
}
function openPriorityMenu() {
fireEvent.click(screen.getByTestId("quick-entry-priority-button"));
}
function mockDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
@@ -690,6 +695,35 @@ describe("QuickEntryBox", () => {
expect(fastToggle.getAttribute("aria-pressed")).toBe("false");
});
it("shows Priority selector in expanded controls", () => {
renderQuickEntryBox({});
expandQuickEntry();
const priorityButton = screen.getByTestId("quick-entry-priority-button");
expect(priorityButton).toBeTruthy();
expect(priorityButton.textContent).toContain("Normal");
});
it("submits selected priority through onCreate payload", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Priority payload task" } });
openPriorityMenu();
fireEvent.click(screen.getByTestId("quick-entry-priority-option-urgent"));
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(props.onCreate).toHaveBeenCalledWith(
expect.objectContaining({
description: "Priority payload task",
priority: "urgent",
}),
);
});
});
it("toggles Fast pressed state", () => {
renderQuickEntryBox({});
expandQuickEntry();
@@ -798,6 +832,47 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("quick-entry-fast-toggle").getAttribute("aria-pressed")).toBe("false");
});
it("resets priority to normal after successful task creation", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Priority reset after save" } });
openPriorityMenu();
fireEvent.click(screen.getByTestId("quick-entry-priority-option-high"));
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(props.onCreate).toHaveBeenCalledTimes(1);
});
expandQuickEntry();
expect(screen.getByTestId("quick-entry-priority-button").textContent).toContain("Normal");
});
it.each([
{ label: "Plan", buttonId: "plan-button" },
{ label: "Subtask", buttonId: "subtask-button" },
])("resets priority to normal after %s flow", async ({ buttonId }) => {
const onPlanningMode = vi.fn();
const onSubtaskBreakdown = vi.fn();
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: `${buttonId} reset` } });
openPriorityMenu();
fireEvent.click(screen.getByTestId("quick-entry-priority-option-urgent"));
fireEvent.click(screen.getByTestId(buttonId));
await waitFor(() => {
expect(buttonId === "plan-button" ? onPlanningMode : onSubtaskBreakdown).toHaveBeenCalled();
});
expandQuickEntry();
expect(screen.getByTestId("quick-entry-priority-button").textContent).toContain("Normal");
});
it("opens dependency dropdown when clicking deps button", () => {
renderQuickEntryBox({});
expandQuickEntry();