FN-7302: compact quick-add workflow selector

Keep the Quick Add workflow selector compact without sacrificing readable workflow menu options.

- Narrow the closed workflow trigger and truncate only its label so action controls keep room.
- Portal and viewport-clamp the expanded workflow menu for right-side columns and mobile layouts.
- Preserve readable wrapping for long workflow names and duplicate-id subtitles in the menu.
- Cover compact trigger sizing, menu readability, and viewport clamping with QuickEntryBox tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7302-quick-add-workflow-trigger.md   |   7 ++
 .../dashboard/app/components/QuickEntryBox.css     |  29 ++++--
 .../dashboard/app/components/QuickEntryBox.tsx     | 114 ++++++++++++++++++++-
 .../components/__tests__/QuickEntryBox.test.tsx    |  79 +++++++++++++-
 4 files changed, 211 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7302

Fusion-Task-Lineage: 4a8042d8-6cc5-4f40-bbcb-71aa5bfb7cd2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 14:20:25 -07:00
parent ea93f68ad9
commit 7ca6c784c4
4 changed files with 211 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make the Quick Add workflow selector compact while keeping long menu names readable.
category: fix
dev: Narrows only the closed QuickEntryBox workflow trigger; menu width and workflow routing remain unchanged.

View File

@@ -140,14 +140,24 @@ FNXC:QuickAddWorkflow 2026-06-30-00:00:
Quick-add workflow targeting sits in the action row as a compact existing-button dropdown. Use tokenized sizing/colors so the selector wraps with Save/Plan controls on narrow Board and List surfaces without introducing a separate visual hierarchy.
FNXC:QuickAddWorkflow 2026-06-30-12:38:
Operators need workflow identity at quick-add time. Keep the trigger wide enough for realistic names, render shared WorkflowIcon output inline, and let no-icon custom workflows collapse without blank icon shells.
Operators need workflow identity at quick-add time. Render shared WorkflowIcon output inline and let no-icon custom workflows collapse without blank icon shells.
FNXC:QuickAddWorkflow 2026-06-30-14:10:
The closed selector should be compact enough to leave room for Save/Fast/Subtask controls, while the opened menu stays wide and viewport-bounded so longer workflow names remain readable.
FNXC:QuickAddWorkflow 2026-06-30-15:05:
Long workflow labels must truncate only in the closed trigger. Menu option text wraps inside the viewport-bounded menu so operators can read the full workflow name and duplicate-id subtitle.
FNXC:QuickAddWorkflow 2026-06-30-16:16:
The wide menu is portaled and fixed-positioned by QuickEntryBox so right-side board columns and mobile wrapped action rows clamp to the viewport instead of overflowing from the compact trigger anchor.
*/
.quick-entry-workflow-wrap {
position: relative;
display: inline-flex;
flex: 1 1 calc(var(--space-xl) * 8);
flex: 0 1 auto;
min-width: 0;
max-width: min(calc(var(--space-xl) * 11), 100%);
width: fit-content;
max-width: min(calc(var(--space-xl) * 5), 100%);
}
.quick-entry-workflow-trigger {
@@ -155,9 +165,9 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
align-items: center;
justify-content: flex-start;
gap: var(--space-xs);
width: 100%;
width: auto;
min-width: 0;
max-width: min(calc(var(--space-xl) * 11), calc(100vw - var(--space-lg)));
max-width: min(calc(var(--space-xl) * 5), calc(100vw - var(--space-lg)));
}
.quick-entry-workflow-icon {
@@ -172,9 +182,7 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
}
.quick-entry-workflow-menu {
position: absolute;
inset-block-start: calc(100% + var(--space-xs));
inset-inline-start: 0;
position: fixed;
width: min(calc(var(--space-xl) * 16), calc(100vw - var(--space-lg)));
min-width: min(calc(var(--space-xl) * 14), calc(100vw - var(--space-lg)));
max-width: calc(100vw - var(--space-lg));
@@ -203,9 +211,8 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
.quick-entry-workflow-option-id {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
white-space: normal;
}
.quick-entry-workflow-option-id {

View File

@@ -196,6 +196,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
);
const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]);
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 previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
/*
@@ -594,7 +597,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (workflowPickerRef.current?.contains(target)) return;
if (workflowPickerPortalRef.current?.contains(target)) return;
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
};
document.addEventListener("mousedown", handleClickOutside);
@@ -877,6 +882,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
if (showWorkflowPicker) {
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
return;
}
if (showAgentPicker) {
@@ -1060,6 +1066,56 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, [getEffectiveViewport]);
const updateWorkflowPickerPosition = useCallback(() => {
const trigger = workflowTriggerRef.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 = Math.min(viewportHeight * (isMobile ? 0.6 : 0.5), 360);
/*
FNXC:QuickAddWorkflow 2026-06-30-16:16:
The workflow menu is wider than its compact trigger, so portal and clamp it against the visual viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow.
*/
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 448)
: Math.min(Math.max(rect.width * 3, 448), 512);
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, 180);
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 180);
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
const maxHeight = Math.max(
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
180,
);
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,
);
setWorkflowPickerPosition({ top, left, width, maxHeight });
}, [getEffectiveViewport]);
const updateDepDropdownPosition = useCallback(() => {
const trigger = depTriggerRef.current;
if (!trigger) return;
@@ -1337,6 +1393,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
};
}, [isRefineMenuOpen, updateRefineMenuPosition]);
// Keep workflow picker portal anchored during scroll/resize
useEffect(() => {
if (!showWorkflowPicker) return;
const handleReposition = () => updateWorkflowPickerPosition();
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);
}
};
}, [showWorkflowPicker, updateWorkflowPickerPosition]);
// Keep dependency dropdown portal anchored during scroll/resize
useEffect(() => {
if (!showDeps) return;
@@ -1688,6 +1769,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
{showWorkflowSelector && (
<div className="quick-entry-workflow-wrap" ref={workflowPickerRef}>
<button
ref={workflowTriggerRef}
type="button"
className="btn btn-sm dep-trigger quick-entry-workflow-trigger"
data-testid="quick-entry-workflow-trigger"
@@ -1705,12 +1787,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowWorkflowPicker((prev) => !prev);
setShowWorkflowPicker((prev) => {
const next = !prev;
if (next) {
updateWorkflowPickerPosition();
} else {
setWorkflowPickerPosition(null);
}
return next;
});
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
}
}}
title={t("tasks.quickEntryWorkflowTitle", "Workflow for the next task")}
@@ -1724,8 +1815,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<span className="quick-entry-workflow-label">{quickEntryWorkflowLabel}</span>
<ChevronDown size={12} aria-hidden="true" />
</button>
{showWorkflowPicker && (
<div className="dep-dropdown quick-entry-workflow-menu" role="listbox" data-testid="quick-entry-workflow-menu">
{showWorkflowPicker && portalRoot && workflowPickerPosition && createPortal(
<div
ref={workflowPickerPortalRef}
className="dep-dropdown quick-entry-workflow-menu"
role="listbox"
data-testid="quick-entry-workflow-menu"
style={{
position: "fixed",
top: `${workflowPickerPosition.top}px`,
left: `${workflowPickerPosition.left}px`,
width: `${workflowPickerPosition.width}px`,
maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined,
overflowY: workflowPickerPosition.maxHeight ? "auto" : undefined,
}}
>
<div className="dep-dropdown-search-header">{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}</div>
{realWorkflowOptions.map((option) => {
const duplicateName = (quickEntryWorkflowNameCounts.get(option.name) ?? 0) > 1;
@@ -1745,6 +1849,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
onClick={() => {
setQuickEntryWorkflowId(option.id);
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
}}
>
<WorkflowIcon workflowId={option.id} icon={option.icon} className="quick-entry-workflow-icon" decorative />
@@ -1755,7 +1860,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</button>
);
})}
</div>
</div>,
portalRoot,
)}
</div>
)}

View File

@@ -1699,17 +1699,90 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("quick-entry-actions").querySelector(".quick-entry-workflow-wrap")).toBeNull();
});
it("uses tokenized responsive CSS for the wider workflow selector", () => {
it("keeps long workflow names readable in the menu while the trigger uses a truncation label", () => {
const longName = "Long workflow name that should remain readable in the opened menu";
renderQuickEntryBox({
workflowId: "wf-long",
defaultWorkflowId: "wf-default",
workflowOptions: [
{ id: "wf-default", name: "Coding", columns: [] },
{ id: "wf-long", name: longName, icon: "🧭", columns: [] },
],
});
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const triggerLabel = trigger.querySelector(".quick-entry-workflow-label");
expect(triggerLabel).toHaveTextContent(longName);
expect(triggerLabel).toHaveClass("quick-entry-workflow-label");
fireEvent.click(trigger);
expect(screen.getByTestId("quick-entry-workflow-menu")).toHaveTextContent(longName);
const optionName = screen.getByTestId("quick-entry-workflow-option-wf-long").querySelector(".quick-entry-workflow-option-name");
expect(optionName).toHaveTextContent(longName);
expect(optionName).toHaveClass("quick-entry-workflow-option-name");
});
it("clamps the wider workflow menu within the viewport from right-side triggers", () => {
const viewportWidth = 800;
vi.spyOn(window, "innerWidth", "get").mockReturnValue(viewportWidth);
vi.spyOn(window, "innerHeight", "get").mockReturnValue(720);
renderQuickEntryBox({
workflowId: "wf-long",
defaultWorkflowId: "wf-default",
workflowOptions: [
{ id: "wf-default", name: "Coding", columns: [] },
{ id: "wf-long", name: "A long readable workflow name", icon: "🧭", columns: [] },
],
});
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
x: 720,
y: 100,
width: 64,
height: 32,
top: 100,
right: 784,
bottom: 132,
left: 720,
toJSON: () => ({}),
} as DOMRect);
fireEvent.click(trigger);
const menu = screen.getByTestId("quick-entry-workflow-menu");
const left = parseFloat(menu.style.left);
const width = parseFloat(menu.style.width);
expect(menu.style.position).toBe("fixed");
expect(left).toBeGreaterThanOrEqual(16);
expect(left + width).toBeLessThanOrEqual(viewportWidth - 16);
expect(width).toBeGreaterThan(64);
});
it("uses tokenized responsive CSS for a compact workflow trigger and readable menu", () => {
const wrapRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-wrap");
const triggerRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-trigger");
const labelRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-label");
const menuRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-menu");
const optionCopyRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-option-copy");
const optionNameRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-option-name,\n.quick-entry-workflow-option-id");
expect(triggerRule).toContain("max-width: min(calc(var(--space-xl) * 11), calc(100vw - var(--space-lg)))");
expect(wrapRule).toContain("flex: 0 1 auto");
expect(wrapRule).toContain("width: fit-content");
expect(wrapRule).toContain("max-width: min(calc(var(--space-xl) * 5), 100%)");
expect(triggerRule).toContain("width: auto");
expect(triggerRule).toContain("max-width: min(calc(var(--space-xl) * 5), calc(100vw - var(--space-lg)))");
expect(triggerRule).toContain("gap: var(--space-xs)");
expect(labelRule).toContain("text-overflow: ellipsis");
expect(menuRule).toContain("position: fixed");
expect(menuRule).not.toContain("inset-inline-start: 0");
expect(menuRule).toContain("width: min(calc(var(--space-xl) * 16), calc(100vw - var(--space-lg)))");
expect(menuRule).toContain("min-width: min(calc(var(--space-xl) * 14), calc(100vw - var(--space-lg)))");
expect(optionCopyRule).toContain("gap: var(--space-2xs)");
expect(`${triggerRule}\n${menuRule}\n${optionCopyRule}`).not.toMatch(/#[0-9a-f]{3,8}|rgb\(|\d+px/i);
expect(optionNameRule).toContain("overflow-wrap: anywhere");
expect(optionNameRule).toContain("white-space: normal");
expect(optionNameRule).not.toContain("text-overflow: ellipsis");
expect(`${wrapRule}\n${triggerRule}\n${labelRule}\n${menuRule}\n${optionCopyRule}\n${optionNameRule}`).not.toMatch(/#[0-9a-f]{3,8}|rgb\(|\d+px/i);
expect(QUICK_ENTRY_BOX_CSS).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?quick-entry-workflow-menu[\s\S]*?min-width: min\(calc\(var\(--space-xl\) \* 11\), calc\(100vw - var\(--space-lg\)\)\)/);
});
});