diff --git a/.changeset/board-composer-menu.md b/.changeset/board-composer-menu.md
new file mode 100644
index 0000000000..84ba3ed527
--- /dev/null
+++ b/.changeset/board-composer-menu.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Tidy the board quick-add composer and add a visible task-card actions menu.
+category: fix
+dev: QuickEntryBox actions split into options/primary groups (Save right-aligned, single divider); TaskCard gains a hover/mobile-visible kebab that opens the existing TaskContextMenu.
diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css
index a91ee795e6..06883258fe 100644
--- a/packages/dashboard/app/components/QuickEntryBox.css
+++ b/packages/dashboard/app/components/QuickEntryBox.css
@@ -170,14 +170,19 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
}
/* Quick Entry Box expanded controls */
+/*
+FNXC:BoardComposer 2026-07-10-12:00:
+First-run review flagged TWO stacked divider lines under the composer textarea: the textarea's own
+border-bottom (which doubles as the focus indicator) plus this panel's border-top. Keep the textarea
+border as the SINGLE divider and drop the panel border-top/padding-top so the chip area reads as one
+section under one line.
+*/
.quick-entry-controls {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-sm);
- padding-top: 8px;
- border-top: 1px solid var(--border);
flex-wrap: wrap;
}
@@ -190,6 +195,36 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
flex: 1 1 auto;
}
+/*
+FNXC:BoardComposer 2026-07-10-12:00:
+The action row is organized as two logical clusters instead of one undifferentiated wrap of chips:
+- `.quick-entry-options-group` — task options (workflow, priority, optional steps, subtask, deps,
+ models, node, agent, GitHub tracking), wrapping left-to-right with one consistent chip gap.
+- `.quick-entry-primary-group` — attach + Fast + Save; margin-left auto keeps it right-aligned so the
+ distinct Save action always ends the row (and wraps as one unit on narrow columns, never splitting
+ Save from its neighbors).
+Chip HEIGHT consistency is already enforced by the FN-7680/FN-7683 fixed-box rule on
+`.quick-entry-actions .btn` above (descendant selector — still applies inside these wrappers); these
+rules only own grouping/spacing/alignment.
+*/
+.quick-entry-options-group {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-xs) var(--space-sm);
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.quick-entry-primary-group {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: var(--space-sm);
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
/*
FNXC:QuickAddActionRow 2026-07-08-00:00:
FN-7680 — Save (`btn btn-task-create btn-sm`) rendered a different box height
diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx
index eacd9a3f37..f302d91e4f 100644
--- a/packages/dashboard/app/components/QuickEntryBox.tsx
+++ b/packages/dashboard/app/components/QuickEntryBox.tsx
@@ -1727,6 +1727,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
touchButtonRef.current = null;
}}
>
+ {/*
+ FNXC:BoardComposer 2026-07-10-12:00:
+ First-run review flagged the quick-add composer as disorganized: chips wrapped into four
+ arbitrary-looking rows with Save buried mid-row. Reorganize into two logical clusters inside
+ the single wrapping action row: an options group (workflow, priority, optional steps, subtask,
+ deps, models, node, agent, GitHub tracking) and a right-aligned primary group (attach, Fast,
+ Save) so the primary Save action always reads last/right. All triggers, popovers, and test ids
+ are unchanged — this is a layout/organization pass only.
+ */}
+
{showWorkflowSelector && (
)}
diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css
index f7ba6df3ad..c1a0c88e6d 100644
--- a/packages/dashboard/app/components/TaskCard.css
+++ b/packages/dashboard/app/components/TaskCard.css
@@ -1456,6 +1456,63 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
outline-offset: 1px;
}
+/*
+FNXC:TaskCardMenu 2026-07-10-12:00:
+Visible ⋯ (card actions) button — the context menu was right-click/long-press only and users never
+found it. Mirrors .card-edit-btn's hover-revealed styling on desktop; stays visible while its menu is
+open (aria-expanded) and on keyboard focus so it never vanishes mid-interaction. Always visible on
+mobile (no hover there) — see the mobile block below, which uses the project mobile MQ including the
+short-landscape variant (FN-5751 lesson: never a desktop-only or portrait-only fix).
+*/
+.card-menu-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--transition-fast), background var(--transition-fast),
+ color var(--transition-fast);
+}
+
+.card:hover .card-menu-btn,
+.card-menu-btn[aria-expanded="true"] {
+ opacity: 1;
+}
+
+.card-menu-btn:hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+.card-menu-btn:focus-visible {
+ opacity: 1;
+ outline: 1px solid var(--todo);
+ outline-offset: 1px;
+}
+
+/* FNXC:TaskCardMenu 2026-07-10-12:00: mobile has no hover — the ⋯ affordance must be always visible
+ with a comfortable touch target. Includes short-landscape phones per the project mobile MQ. */
+@media (max-width: 768px), (max-height: 480px) {
+ .card-menu-btn {
+ opacity: 1;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-md);
+ }
+
+ .card-menu-btn svg {
+ width: 16px;
+ height: 16px;
+ }
+}
+
/* Delete button - visible on hover for triage column */
.card-delete-btn {
display: flex;
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index cb3f3c8885..fc73d96a44 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
import { createPortal } from "react-dom";
-import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye } from "lucide-react";
+import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
import {
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
@@ -915,6 +915,13 @@ function TaskCardComponent({
const touchOpenHandledRef = useRef(false);
const cardRef = useRef(null);
const contextMenuRef = useRef(null);
+ /*
+ FNXC:TaskCardMenu 2026-07-10-12:00:
+ Ref for the visible card actions (⋯) button, so the context-menu outside-pointerdown closer can
+ ignore presses on the button itself — otherwise pointerdown would close the menu and the following
+ click would immediately reopen it, breaking the toggle affordance.
+ */
+ const menuButtonRef = useRef(null);
const sendBackRef = useRef(null);
const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
@@ -2442,6 +2449,26 @@ function TaskCardComponent({
clearLongPressTimer();
}, [clearLongPressTimer]);
+ /*
+ FNXC:TaskCardMenu 2026-07-10-12:00:
+ First-run review: the card's Edit/Delete/Review/New chat/Interventions actions were ONLY reachable
+ via right-click (or touch long-press), which the user never discovered. Add a visible ⋯ button that
+ opens the SAME portaled TaskContextMenu (same `contextMenuActions` model — no duplicated item
+ logic), anchored under the button. Toggles closed when the menu is already open. Rendered only when
+ `hasContextMenuActions` so no empty button shell appears on handler-less surfaces (e.g. read-only
+ docks). Hover-revealed on desktop, always visible on mobile/touch (see TaskCard.css).
+ */
+ const handleMenuButtonClick = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (contextMenuPosition) {
+ closeContextMenu();
+ return;
+ }
+ const rect = e.currentTarget.getBoundingClientRect();
+ suppressNextCardClickRef.current = true;
+ openContextMenuAt(rect.left, rect.bottom + MENU_BUTTON_MENU_GAP);
+ }, [closeContextMenu, contextMenuPosition, openContextMenuAt]);
+
/*
FNXC:TaskContextMenu 2026-07-01-00:00:
Board columns intentionally clip and scroll their bodies, so card context menus must be portaled to document.body and positioned in viewport coordinates. Clamp after render using the measured menu size so right-click, keyboard, and long-press menus escape column borders without weakening board overflow containment.
@@ -2464,6 +2491,9 @@ function TaskCardComponent({
if (!contextMenuPosition) return;
const handleDocumentPointerDown = (event: PointerEvent) => {
if (contextMenuRef.current?.contains(event.target as Node)) return;
+ // FNXC:TaskCardMenu 2026-07-10-12:00: let the ⋯ button's own click handler toggle the menu closed
+ // instead of racing it shut on pointerdown (see menuButtonRef comment above).
+ if (menuButtonRef.current?.contains(event.target as Node)) return;
closeContextMenu();
};
const handleDocumentKeyDown = (event: KeyboardEvent) => {
@@ -3119,6 +3149,29 @@ function TaskCardComponent({
{task.size}
)}
+ {/*
+ FNXC:TaskCardMenu 2026-07-10-12:00:
+ Visible entry point for the card's action menu (Edit/Delete/Review/New chat/Interventions…)
+ — previously right-click/long-press only and therefore undiscoverable. Opens the same
+ portaled TaskContextMenu anchored at this button. Only rendered when the menu has actions
+ (no empty shell); applies on every surface that renders TaskCard (Board columns, worktree
+ groups, dock task lists).
+ */}
+ {hasContextMenuActions && (
+
+
+
+ )}
{showStalledReview && stalledReview && (
@@ -3590,6 +3643,8 @@ const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
const TOUCH_CONTEXT_MENU_DELAY_MS = 550; // milliseconds
const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
const KEYBOARD_CONTEXT_MENU_OFFSET = 32;
+// FNXC:TaskCardMenu 2026-07-10-12:00: vertical gap between the ⋯ button and the menu it anchors.
+const MENU_BUTTON_MENU_GAP = 4;
const MAX_TITLE_LENGTH = 140;
function truncate(s: string | undefined, max: number): string {
diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
index c05dfeddcc..83e0c8abb8 100644
--- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
+++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
@@ -405,17 +405,23 @@ function mockMobileViewport() {
});
}
+/*
+FNXC:BoardComposer 2026-07-10-12:00:
+DOM order mirrors the reorganized composer action row: the options group (priority, subtask, deps,
+models, node, agent, GitHub) comes first, followed by the right-aligned primary group
+(attach, Fast, Save) with Save as the LAST control.
+*/
const QUICK_ENTRY_ACTION_BUTTONS = [
- ["Save", "quick-entry-save"],
- ["Attach", "quick-entry-attach"],
- ["Fast", "quick-entry-fast-toggle"],
- ["GitHub", "quick-entry-github-toggle"],
["Priority", "quick-entry-priority-button"],
["Subtask", "subtask-button"],
["Deps", "quick-entry-deps"],
["Models", "quick-entry-models"],
["Node", "quick-entry-node-button"],
["Agent", "quick-entry-agent-button"],
+ ["GitHub", "quick-entry-github-toggle"],
+ ["Attach", "quick-entry-attach"],
+ ["Fast", "quick-entry-fast-toggle"],
+ ["Save", "quick-entry-save"],
] as const;
describe("QuickEntryBox", () => {
@@ -797,7 +803,9 @@ describe("QuickEntryBox", () => {
] as const;
const allActionButtons = QUICK_ENTRY_ACTION_BUTTONS;
- const actionButtonsWithSaveLast = [...allActionButtons.slice(1), allActionButtons[0]];
+ // FNXC:BoardComposer 2026-07-10-12:00: Save is already the last action in the reorganized row,
+ // so iterating in DOM order clicks Save (which submits and resets the form) last.
+ const actionButtonsWithSaveLast = allActionButtons;
function getActionButtonTestIdsInDomOrder() {
const actionsContainer = screen.getByTestId("quick-entry-actions");
@@ -843,13 +851,24 @@ describe("QuickEntryBox", () => {
});
});
- it("places Attach immediately after Save in DOM order", () => {
+ /*
+ FNXC:BoardComposer 2026-07-10-12:00:
+ The primary group ends the action row as [Attach, Fast, Save]: Save is the LAST control (right-
+ aligned primary action) and Attach/Fast sit immediately beside it inside the same cluster.
+ */
+ it("ends the action row with the primary group: Attach and Fast immediately before Save, Save last", () => {
renderQuickEntryBox({});
expandQuickEntry();
const actionButtonTestIds = getActionButtonTestIdsInDomOrder();
- expect(actionButtonTestIds.slice(0, 2)).toEqual(["quick-entry-save", "quick-entry-attach"]);
- expect(actionButtonTestIds.indexOf("quick-entry-attach")).toBe(actionButtonTestIds.indexOf("quick-entry-save") + 1);
+ expect(actionButtonTestIds.slice(-3)).toEqual(["quick-entry-attach", "quick-entry-fast-toggle", "quick-entry-save"]);
+
+ const primaryGroup = screen.getByTestId("quick-entry-primary-group");
+ for (const testId of ["quick-entry-attach", "quick-entry-fast-toggle", "quick-entry-save"]) {
+ expect(primaryGroup.contains(screen.getByTestId(testId))).toBe(true);
+ }
+ const optionsGroup = screen.getByTestId("quick-entry-options-group");
+ expect(optionsGroup.contains(screen.getByTestId("quick-entry-save"))).toBe(false);
});
it("action buttons appear in correct DOM order after reorder", () => {
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index d6954751a0..e6b6f73349 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -43,6 +43,8 @@ vi.mock("lucide-react", () => ({
// FN-7592: the overseer badge now renders an icon child instead of a text label,
// so tests must see a real SVG (like Zap) rather than a no-op render.
Eye: () => ,
+ // FNXC:TaskCardMenu 2026-07-10-12:00: visible ⋯ card-actions button icon.
+ MoreHorizontal: () => ,
}));
vi.mock("../ProviderIcon", () => ({
@@ -448,6 +450,64 @@ describe("TaskCard", () => {
}
});
+ /*
+ FNXC:TaskCardMenu 2026-07-10-12:00:
+ The card actions menu must ALSO be reachable from the visible ⋯ button (first-run users never
+ discovered right-click). The button must open the exact same TaskContextMenu (same items — no
+ duplicated menu logic), anchored as a viewport portal, and toggle closed on a second press.
+ */
+ it("opens the same card context menu from the visible ⋯ button and toggles it closed", async () => {
+ const cleanupGeometry = mockBoardContextMenuGeometry();
+ const onOpenDetail = vi.fn();
+ const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
+ try {
+ render(
+ ,
+ );
+
+ // Capture the canonical right-click menu item set first.
+ fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
+ await waitFor(() => expectBoardContextMenuPortaled());
+ const rightClickItems = screen.getAllByRole("menuitem").map((item) => item.textContent);
+ expect(rightClickItems.length).toBeGreaterThan(0);
+ fireEvent.keyDown(document, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
+
+ // The ⋯ button opens the SAME menu (identical items) as right-click, portaled to the viewport.
+ const menuButton = screen.getByTestId("card-menu-btn-FN-001");
+ expect(menuButton).toHaveAttribute("aria-haspopup", "menu");
+ expect(menuButton).toHaveAttribute("aria-expanded", "false");
+ fireEvent.click(menuButton);
+ await waitFor(() => expectBoardContextMenuPortaled());
+ expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(rightClickItems);
+ expect(menuButton).toHaveAttribute("aria-expanded", "true");
+ expect(onOpenDetail).not.toHaveBeenCalled();
+
+ // A second press toggles the menu closed — the document pointerdown closer must not race it
+ // shut and immediately reopen it.
+ fireEvent.pointerDown(menuButton);
+ fireEvent.click(menuButton);
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
+ expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
+ expect(menuButton).toHaveAttribute("aria-expanded", "false");
+
+ // Selecting an action from the button-opened menu invokes the shared handler and closes.
+ fireEvent.click(menuButton);
+ await waitFor(() => expectBoardContextMenuPortaled());
+ fireEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
+ await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(onOpenDetail).not.toHaveBeenCalled();
+ } finally {
+ cleanupGeometry();
+ }
+ });
+
it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();