FN-8676: fix Quick Add upward menu anchoring

Keep Quick Add portal menus attached to their triggers when they open upward.

- Position upward menus with bottom anchors rather than height-capped top offsets.
- Apply shared positioning to Quick Add pickers and the custom model dropdown.
- Add desktop and mobile regression coverage and a patch changeset.

Files changed:
 .changeset/quick-add-upward-menu-anchor.md         |  7 ++++
 .../app/components/CustomModelDropdown.tsx         | 18 ++++++--
 .../dashboard/app/components/QuickEntryBox.tsx     | 48 +++++++++++++++-------
 .../__tests__/CustomModelDropdown.test.tsx         | 40 +++++++++++++++---
 .../components/__tests__/QuickEntryBox.test.tsx    | 46 ++++++++++++++++++---
 .../app/utils/__tests__/fixedMenuPosition.test.ts  | 12 +++---
 packages/dashboard/app/utils/fixedMenuPosition.ts  | 22 ++++++----
 7 files changed, 151 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-8676

Fusion-Task-Lineage: e48b4b2c-9675-42c6-8712-83cb4a0ca3d8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 01:00:57 -07:00
parent 1e83dcceec
commit df56790c9b
7 changed files with 151 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep upward Quick Add model and priority menus anchored to their trigger.
category: fix
dev: Portal menus now use bottom anchoring when opening upward.

View File

@@ -42,7 +42,8 @@ export interface CustomModelDropdownProps {
}
interface DropdownPosition {
top: number;
top: number | null;
bottom: number | null;
left: number;
width: number;
maxHeight: number;
@@ -362,12 +363,22 @@ export function CustomModelDropdown({
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - dropdownWidth,
) + offsetLeft;
/*
FNXC:ModelDropdown 2026-08-01-07:11:
The model menu's maxHeight includes a 160px scroll floor, so upward top placement based on that
cap separates short model lists from their trigger. Anchor the bottom instead; the visual-viewport
offset preserves the existing effective-viewport coordinate conversion for keyboard and zoom cases.
*/
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
? null
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
const bottom = openUpward
? viewportHeight + offsetTop - rect.top + gap
: null;
setDropdownPosition({
top,
bottom,
left,
width: dropdownWidth,
maxHeight,
@@ -593,7 +604,8 @@ export function CustomModelDropdown({
data-menu-width={menuWidth}
onKeyDown={handleKeyDown}
style={{
top: `${dropdownPosition.top}px`,
top: dropdownPosition.bottom === null ? `${dropdownPosition.top}px` : "auto",
bottom: dropdownPosition.bottom === null ? undefined : `${dropdownPosition.bottom}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
maxHeight: `${dropdownPosition.maxHeight}px`,

View File

@@ -221,14 +221,20 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
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);
/*
FNXC:QuickAddMenuAnchor 2026-08-01-07:11:
Preserve both shared-helper vertical anchors in Quick Add state. Portal styles must use `bottom`
with `top: auto` upward, because a natural-height menu cannot remain attached when top is derived
from its independent max-height scroll cap.
*/
const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
// Dependency dropdown portal refs and state
const depTriggerRef = useRef<HTMLButtonElement>(null);
const depDropdownPortalRef = useRef<HTMLDivElement>(null);
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
@@ -236,7 +242,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const workflowPickerRef = useRef<HTMLDivElement>(null);
const workflowTriggerRef = useRef<HTMLButtonElement>(null);
const workflowPickerPortalRef = useRef<HTMLDivElement>(null);
const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
/*
@@ -1106,9 +1112,9 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
/*
FNXC:QuickAddDepsMenu 2026-07-25-12:00:
All Quick Add portaled menus (Deps, Models, workflow, agent, node, priority) share anchor-first
layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or clamping
top away from the trigger to preserve a min height floor, made Deps (and siblings) float too high
and unattached when free space was tight.
layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or deriving
upward `top` from a height cap, made short menus float too high; upward portals consume `bottom`
and `top: auto` so their rendered bottom remains attached regardless of content height.
*/
const updateModelMenuPosition = useCallback(() => {
const trigger = modelTriggerRef.current;
@@ -1136,6 +1142,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setModelMenuPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1173,6 +1180,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setWorkflowPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1206,6 +1214,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setDepDropdownPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1238,6 +1247,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setAgentPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1270,6 +1280,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setNodePickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1292,6 +1303,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
});
setPriorityPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
@@ -1801,7 +1813,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
data-testid="quick-entry-workflow-menu"
style={{
position: "fixed",
top: `${workflowPickerPosition.top}px`,
top: workflowPickerPosition.bottom === null ? `${workflowPickerPosition.top}px` : "auto",
bottom: workflowPickerPosition.bottom === null ? undefined : `${workflowPickerPosition.bottom}px`,
left: `${workflowPickerPosition.left}px`,
width: `${workflowPickerPosition.width}px`,
maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined,
@@ -1929,7 +1942,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${depDropdownPosition.top}px`,
top: depDropdownPosition.bottom === null ? `${depDropdownPosition.top}px` : "auto",
bottom: depDropdownPosition.bottom === null ? undefined : `${depDropdownPosition.bottom}px`,
left: `${depDropdownPosition.left}px`,
width: `${depDropdownPosition.width}px`,
maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined,
@@ -2032,7 +2046,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${nodePickerPosition.top}px`,
top: nodePickerPosition.bottom === null ? `${nodePickerPosition.top}px` : "auto",
bottom: nodePickerPosition.bottom === null ? undefined : `${nodePickerPosition.bottom}px`,
left: `${nodePickerPosition.left}px`,
width: `${nodePickerPosition.width}px`,
maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined,
@@ -2108,7 +2123,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${agentPickerPosition.top}px`,
top: agentPickerPosition.bottom === null ? `${agentPickerPosition.top}px` : "auto",
bottom: agentPickerPosition.bottom === null ? undefined : `${agentPickerPosition.bottom}px`,
left: `${agentPickerPosition.left}px`,
width: `${agentPickerPosition.width}px`,
maxHeight: agentPickerPosition.maxHeight ? `${agentPickerPosition.maxHeight}px` : undefined,
@@ -2293,7 +2309,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${priorityPickerPosition.top}px`,
top: priorityPickerPosition.bottom === null ? `${priorityPickerPosition.top}px` : "auto",
bottom: priorityPickerPosition.bottom === null ? undefined : `${priorityPickerPosition.bottom}px`,
left: `${priorityPickerPosition.left}px`,
width: `${priorityPickerPosition.width}px`,
maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined,
@@ -2369,7 +2386,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
data-testid="model-nested-menu"
style={{
position: "fixed",
top: `${modelMenuPosition.top}px`,
top: modelMenuPosition.bottom === null ? `${modelMenuPosition.top}px` : "auto",
bottom: modelMenuPosition.bottom === null ? undefined : `${modelMenuPosition.bottom}px`,
left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`,
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,

View File

@@ -572,10 +572,41 @@ describe("CustomModelDropdown", () => {
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal.style.left).toBe("239px");
expect(portal.style.width).toBe("120px");
expect(portal.style.top).toBe("196px");
expect(portal.style.top).toBe("auto");
expect(portal.style.bottom).toBe("111px");
expect(portal.style.maxHeight).toBe("360px");
});
it.each([
{ name: "desktop", width: 1024, height: 760 },
{ name: "mobile", width: 375, height: 760 },
])("bottom-anchors an empty model list upward on $name", async ({ width, height }) => {
const user = userEvent.setup();
vi.spyOn(window, "innerWidth", "get").mockReturnValue(width);
vi.spyOn(window, "innerHeight", "get").mockReturnValue(height);
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: width <= 768 && query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList));
render(<CustomModelDropdown label="Empty Model" value="" onChange={vi.fn()} models={[]} />);
const trigger = screen.getByRole("button", { name: "Empty Model" });
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
top: 700, bottom: 728, left: 24, width: 120, right: 144, height: 28, x: 24, y: 700, toJSON: () => ({}),
});
await user.click(trigger);
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal.style.top).toBe("auto");
expect(portal.style.bottom).toBe(`${height - 700 + 4}px`);
});
describe("Readable menu sizing", () => {
const setupBoundingRectMock = (rectValues: DOMRect) => {
const originalGetBCR = Element.prototype.getBoundingClientRect;
@@ -1070,10 +1101,9 @@ describe("CustomModelDropdown", () => {
await user.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal");
const top = parseFloat(portal.style.top);
// Should position upward: rect.top - estimatedHeight - 4 = 710 - 320 - 4 = 386
expect(top).toBe(386);
// Upward placement must be independent of the max-height scroll cap.
expect(portal.style.top).toBe("auto");
expect(portal.style.bottom).toBe("94px");
} finally {
restore();
Object.defineProperty(window, "innerHeight", {

View File

@@ -3126,13 +3126,11 @@ describe("QuickEntryBox", () => {
expect(dropdown.classList.contains("dep-dropdown--portal")).toBe(true);
expect(dropdown.style.position).toBe("fixed");
const top = parseFloat(dropdown.style.top);
const maxHeight = parseFloat(dropdown.style.maxHeight);
const gap = 4;
// Menu bottom edge must sit `gap` above the trigger top (open upward, attached).
expect(top + maxHeight).toBeCloseTo(150 - gap, 5);
// Must not float at the old detached clamp (top ≈ 16 with maxHeight 200 leaving a large gap).
expect(top + maxHeight + gap).toBeCloseTo(150, 5);
// Bottom anchoring stays attached even when the menu's natural list is shorter than maxHeight.
expect(dropdown.style.top).toBe("auto");
expect(dropdown.style.bottom).toBe(`${viewportHeight - 150 + gap}px`);
expect(maxHeight).toBeLessThanOrEqual(150 - 16 - gap);
});
@@ -4466,6 +4464,44 @@ describe("QuickEntryBox", () => {
expect(menu.style.width).toBeTruthy();
});
it.each([
{ name: "desktop", width: 1280, height: 760 },
{ name: "mobile", width: 375, height: 760 },
])("bottom-anchors short priority and populated model menus upward on $name", ({ width, height }) => {
const widthDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientWidth");
const heightDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientHeight");
Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: width });
Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: height });
try {
renderQuickEntryBox({});
expandQuickEntry();
const priorityTrigger = screen.getByTestId("quick-entry-priority-button");
vi.spyOn(priorityTrigger, "getBoundingClientRect").mockReturnValue({
top: 700, bottom: 728, left: 24, width: 80, right: 104, height: 28, x: 24, y: 700, toJSON: () => ({}),
});
openPriorityMenu();
const priorityMenu = screen.getByTestId("quick-entry-priority-option-normal").closest(".priority-picker-dropdown--portal") as HTMLElement;
expect(priorityMenu.style.top).toBe("auto");
expect(priorityMenu.style.bottom).toBe(`${height - 700 + 4}px`);
const modelTrigger = screen.getByTestId("quick-entry-models");
vi.spyOn(modelTrigger, "getBoundingClientRect").mockReturnValue({
top: 700, bottom: 728, left: 24, width: 80, right: 104, height: 28, x: 24, y: 700, toJSON: () => ({}),
});
openModelMenu();
const modelMenu = screen.getByTestId("model-nested-menu");
expect(modelMenu.style.top).toBe("auto");
expect(modelMenu.style.bottom).toBe(`${height - 700 + 4}px`);
} finally {
if (widthDescriptor) Object.defineProperty(document.documentElement, "clientWidth", widthDescriptor);
else delete (document.documentElement as { clientWidth?: number }).clientWidth;
if (heightDescriptor) Object.defineProperty(document.documentElement, "clientHeight", heightDescriptor);
else delete (document.documentElement as { clientHeight?: number }).clientHeight;
}
});
it("does not close model menu when clicking inside CustomModelDropdown portal", () => {
renderQuickEntryBox({});
expandQuickEntry();

View File

@@ -29,6 +29,7 @@ describe("computeFixedMenuPosition", () => {
expect(position.openUpward).toBe(false);
expect(position.top).toBe(baseTrigger.bottom + 4);
expect(position.bottom).toBeNull();
expect(position.left).toBe(baseTrigger.left);
expect(position.width).toBe(280);
expect(position.maxHeight).toBe(320);
@@ -47,8 +48,8 @@ describe("computeFixedMenuPosition", () => {
});
expect(position.openUpward).toBe(true);
// Menu bottom edge is gap above the trigger; top = trigger.top - gap - maxHeight.
expect(position.top + position.maxHeight).toBe(trigger.top - 4);
expect(position.top).toBeNull();
expect(position.bottom).toBe(600 - trigger.top + 4);
expect(position.maxHeight).toBeLessThanOrEqual(320);
expect(position.maxHeight).toBeGreaterThan(0);
});
@@ -73,10 +74,9 @@ describe("computeFixedMenuPosition", () => {
// Old math floored maxHeight to 200 and clamped top to verticalPadding (16), detaching the menu.
expect(position.openUpward).toBe(true);
expect(position.maxHeight).toBe(130);
expect(position.top).toBe(trigger.top - gap - position.maxHeight);
expect(position.top).toBe(16); // verticalPadding
// Still attached: menu bottom + gap === trigger top
expect(position.top + position.maxHeight + gap).toBe(trigger.top);
expect(position.top).toBeNull();
// The CSS bottom offset is height-independent, even when maxHeight shrinks.
expect(position.bottom).toBe(viewportHeight - trigger.top + gap);
});
it("shrinks maxHeight when opening downward into a short viewport instead of lifting top off the trigger", () => {

View File

@@ -7,9 +7,9 @@ Portaled `position: fixed` menus (Quick Add Deps and sibling pickers) must stay
(same failure class as the TaskDetail Activity menu fix).
FNXC:QuickAddDepsMenu 2026-07-25-12:00:
Anchor-first: place the menu immediately below (or above) the trigger, then shrink `maxHeight` to the
real free space. Never clamp `top` away from the trigger to preserve a preferred/min height floor —
that produced the Deps menu floating too high and unattached when space was tight.
Anchor-first: place the menu immediately below the trigger or anchor its bottom immediately above it,
then shrink `maxHeight` to the real free space. Computing upward `top` from a preferred/clamped
height detached short menus; never clamp placement to preserve a preferred/min height floor.
*/
export interface FixedMenuTriggerRect {
@@ -33,7 +33,8 @@ export interface FixedMenuPositionInput {
}
export interface FixedMenuPosition {
top: number;
top: number | null;
bottom: number | null;
left: number;
width: number;
maxHeight: number;
@@ -88,13 +89,18 @@ export function computeFixedMenuPosition(input: FixedMenuPositionInput): FixedMe
if (openUpward) {
const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceAbove));
// Bottom edge of the menu sits `gap` above the trigger top — always attached.
const top = input.triggerRect.top - gap - maxHeight;
return { top, left, width, maxHeight, openUpward: true };
/*
FNXC:QuickAddMenuAnchor 2026-08-01-07:11:
Upward Quick Add menus must anchor their bottom edge to the trigger because maxHeight is only a
scroll cap. Positioning from a preferred/clamped height detaches short option lists; bottom
anchoring keeps every rendered height exactly `gap` above the trigger.
*/
const bottom = input.viewportHeight - input.triggerRect.top + gap;
return { top: null, bottom, left, width, maxHeight, openUpward: true };
}
const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceBelow));
// Top edge sits `gap` below the trigger bottom — always attached.
const top = input.triggerRect.bottom + gap;
return { top, left, width, maxHeight, openUpward: false };
return { top, bottom: null, left, width, maxHeight, openUpward: false };
}