feat(FN-1480): merge fusion/fn-1480

This commit is contained in:
gsxdsm
2026-04-10 18:09:00 -07:00
parent f9d65f5447
commit 43802b165c
4 changed files with 263 additions and 48 deletions

View File

@@ -118,6 +118,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(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);
const depDropdownPortalRef = useRef<HTMLDivElement>(null);
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -677,6 +681,66 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, [getEffectiveViewport]);
const updateDepDropdownPosition = useCallback(() => {
const trigger = depTriggerRef.current;
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);
// Wider dropdown for dependency selection - easier to read task names
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 360)
: Math.max(rect.width, 280);
const width = Math.min(
preferredWidth,
Math.max(viewportWidth - horizontalPadding * 2, 240),
);
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, 200);
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 200);
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
const maxHeight = Math.max(
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
200,
);
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,
);
setDepDropdownPosition({
top,
left,
width,
maxHeight,
});
}, [getEffectiveViewport]);
// Keep model menu portal anchored during scroll/resize
useEffect(() => {
if (!isModelMenuOpen) return;
@@ -727,6 +791,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
};
}, [isRefineMenuOpen, updateRefineMenuPosition]);
// Keep dependency dropdown portal anchored during scroll/resize
useEffect(() => {
if (!showDeps) return;
const handleReposition = () => updateDepDropdownPosition();
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);
}
};
}, [showDeps, updateDepDropdownPosition]);
const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningProvider(next.provider);
@@ -1045,6 +1134,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<div className="dep-trigger-wrap">
<button
ref={depTriggerRef}
type="button"
className="btn btn-sm dep-trigger"
data-testid="quick-entry-deps"
@@ -1056,6 +1146,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowAgentPicker(false);
// Position the dropdown before rendering
updateDepDropdownPosition();
} else {
setDepDropdownPosition(null);
}
return next;
});
@@ -1064,51 +1158,65 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
return (
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">No existing tasks</div>
) : (
filtered.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleDep(t.id)}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
);
})()}
</div>
{/* Dependency dropdown rendered via portal for proper viewport positioning */}
{showDeps && portalRoot && depDropdownPosition && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
return createPortal(
<div
ref={depDropdownPortalRef}
className="dep-dropdown dep-dropdown--portal"
onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${depDropdownPosition.top}px`,
left: `${depDropdownPosition.left}px`,
width: `${depDropdownPosition.width}px`,
maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined,
overflowY: depDropdownPosition.maxHeight ? "auto" : undefined,
}}
>
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">No existing tasks</div>
) : (
filtered.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleDep(t.id)}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 60)}</span>
</div>
))
)}
</div>,
portalRoot,
);
})()}
<button
type="button"

View File

@@ -815,6 +815,105 @@ describe("QuickEntryBox", () => {
});
});
describe("dependency dropdown readability (FN-1480)", () => {
it("renders dependency dropdown with portal class for viewport escaping", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
openDepsMenu();
const dropdown = document.querySelector(".dep-dropdown");
expect(dropdown).toBeTruthy();
// Portal version should have the --portal modifier class
expect(dropdown?.classList.contains("dep-dropdown--portal")).toBe(true);
});
it("shows long task titles without aggressive truncation", () => {
const longTitleTask: Task = {
id: "FN-999",
title: "This is a very long task title that exceeds the previous truncation limit and should now be more readable",
description: "Task description",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-04-10T00:00:00Z",
updatedAt: "2026-04-10T00:00:00Z",
};
renderQuickEntryBox({ tasks: [longTitleTask] });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with long dep" } });
openDepsMenu();
const titleSpan = document.querySelector(".dep-dropdown-title");
expect(titleSpan).toBeTruthy();
// The new truncation limit is 60 characters, so a long title should be truncated
// but with ellipsis, showing it's now readable (not cut off at 30)
const titleText = titleSpan?.textContent || "";
expect(titleText.length).toBe(60 + 1); // 60 chars + ellipsis
expect(titleText).toContain("…");
// Verify the content is from the title, not just the id
expect(titleText.startsWith("This is a very long task title")).toBe(true);
});
it("closes dependency dropdown on Escape key", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
openDepsMenu();
// Dropdown should be open
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
// Press Escape
fireEvent.keyDown(textarea, { key: "Escape" });
// Dropdown should be closed
expect(document.querySelector(".dep-dropdown")).toBeNull();
});
it("does not close dependency dropdown when clicking inside the dropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
openDepsMenu();
// Dropdown should be open
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
// Click on the search input inside the dropdown
const searchInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(searchInput).toBeTruthy();
fireEvent.click(searchInput);
// Dropdown should still be open
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
});
it("closes dependency dropdown when switching to other controls", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
openDepsMenu();
// Dropdown should be open
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
// Click the models button
fireEvent.click(screen.getByTestId("quick-entry-models"));
// Dropdown should be closed
expect(document.querySelector(".dep-dropdown")).toBeNull();
});
});
it("calls onPlanningMode and clears input when Plan clicked", async () => {
const onPlanningMode = vi.fn();
const { props } = renderQuickEntryBox({ onPlanningMode });

View File

@@ -5754,14 +5754,22 @@ body {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
min-width: 240px;
max-width: 320px;
max-height: 240px;
min-width: 280px;
max-width: 400px;
max-height: 320px;
overflow-y: auto;
z-index: 200;
box-shadow: var(--shadow-lg);
}
/* Portal-rendered dependency dropdown - uses fixed positioning from JS */
.dep-dropdown--portal {
position: fixed !important;
top: auto;
left: auto;
margin-top: 0;
}
.dep-dropdown-search-header {
position: sticky;
top: 0;
@@ -25876,7 +25884,6 @@ html .column.drag-over * {
.quick-entry-box .dep-dropdown {
max-width: calc(100vw - 32px);
left: 8px;
}
}