diff --git a/.changeset/fn-7302-quick-add-workflow-trigger.md b/.changeset/fn-7302-quick-add-workflow-trigger.md new file mode 100644 index 0000000000..395f5a2202 --- /dev/null +++ b/.changeset/fn-7302-quick-add-workflow-trigger.md @@ -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. diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index 29a4392181..a5a93b1bf3 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -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 { diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index a6940d016f..7adf77505b 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -196,6 +196,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, ); const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]); const workflowPickerRef = useRef(null); + const workflowTriggerRef = useRef(null); + const workflowPickerPortalRef = useRef(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 && (
- {showWorkflowPicker && ( -
+ {showWorkflowPicker && portalRoot && workflowPickerPosition && createPortal( +
{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}
{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); }} > @@ -1755,7 +1860,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, ); })} -
+
, + portalRoot, )}
)} diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index c650e9c77c..df41cdcc2d 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -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\)\)\)/); }); });