fix(dashboard): organize board quick-add composer and expose task actions menu

- Quick-add composer: single divider, option chips grouped left
  (workflow, priority, steps, deps, models, node, agent, GitHub) and
  primary actions right-aligned (attach, fast, Save last).
- Task cards get a visible kebab button (hover on desktop, always on
  mobile) opening the same context menu as right-click, which was
  previously undiscoverable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-10 16:06:25 -07:00
parent 6adc0095dc
commit ec1a2eafcf
7 changed files with 320 additions and 67 deletions

View File

@@ -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.

View File

@@ -170,14 +170,19 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
} }
/* Quick Entry Box expanded controls */ /* 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 { .quick-entry-controls {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--space-sm); gap: var(--space-sm);
margin-top: var(--space-sm); margin-top: var(--space-sm);
padding-top: 8px;
border-top: 1px solid var(--border);
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -190,6 +195,36 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
flex: 1 1 auto; 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: FNXC:QuickAddActionRow 2026-07-08-00:00:
FN-7680 — Save (`btn btn-task-create btn-sm`) rendered a different box height FN-7680 — Save (`btn btn-task-create btn-sm`) rendered a different box height

View File

@@ -1727,6 +1727,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
touchButtonRef.current = null; 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.
*/}
<div className="quick-entry-options-group" data-testid="quick-entry-options-group">
{showWorkflowSelector && ( {showWorkflowSelector && (
<div className="quick-entry-workflow-wrap" ref={workflowPickerRef}> <div className="quick-entry-workflow-wrap" ref={workflowPickerRef}>
<button <button
@@ -1827,62 +1837,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div> </div>
)} )}
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSaveClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="quick-entry-save"
title={t("tasks.createTaskTitle", "Create task")}
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.save", "Save")}
</button>
{/* FNXC:QuickAddAttachments 2026-06-30-00:00: Keep the Quick Add attachment affordance immediately to the right of Save while preserving the icon-only label, hidden file input trigger, and pending-count badge. */}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-icon btn-sm quick-entry-attach-button"
data-testid="quick-entry-attach"
onClick={() => fileInputRef.current?.click()}
aria-label={attachLabel}
title={attachLabel}
>
<Paperclip size={12} aria-hidden="true" />
{pendingImages.length > 0 && (
<span className="quick-entry-attach-count" aria-hidden="true">{pendingImages.length}</span>
)}
</button>
<button
type="button"
className={`btn btn-sm ${isFastMode ? "btn-primary" : ""}`}
onClick={toggleFastMode}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={isFastMode}
data-testid="quick-entry-fast-toggle"
title={t("tasks.toggleFastMode", "Toggle fast execution mode")}
>
{t("tasks.fast", "Fast")}
</button>
<button
type="button"
className={`btn btn-sm ${effectiveGithubTracking ? "btn-primary" : ""}`}
onClick={() => {
setGithubTrackingOverride((prev) => !(prev ?? projectGithubTrackingDefault));
}}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={effectiveGithubTracking}
data-testid="quick-entry-github-toggle"
title={githubToggleLabel}
aria-label={githubToggleLabel}
>
<ProviderIcon provider="github" size="sm" />
</button>
<div className="priority-trigger-wrap" ref={priorityPickerRef}> <div className="priority-trigger-wrap" ref={priorityPickerRef}>
<button <button
type="button" type="button"
@@ -2259,6 +2213,72 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
portalRoot, portalRoot,
)} )}
<button
type="button"
className={`btn btn-sm ${effectiveGithubTracking ? "btn-primary" : ""}`}
onClick={() => {
setGithubTrackingOverride((prev) => !(prev ?? projectGithubTrackingDefault));
}}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={effectiveGithubTracking}
data-testid="quick-entry-github-toggle"
title={githubToggleLabel}
aria-label={githubToggleLabel}
>
<ProviderIcon provider="github" size="sm" />
</button>
</div>
{/*
FNXC:BoardComposer 2026-07-10-12:00:
Primary action cluster: attach + Fast sit directly beside Save, and Save is the LAST control in
DOM order so it is right-aligned (margin-left auto on the cluster) and reads as the composer's
primary action. Save keeps its distinct `btn-task-create` styling.
FNXC:QuickAddAttachments 2026-06-30-00:00 (relocated 2026-07-10): the attachment affordance
stays adjacent to Save (now immediately to its LEFT) preserving the icon-only label, hidden
file input trigger, and pending-count badge.
*/}
<div className="quick-entry-primary-group" data-testid="quick-entry-primary-group">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-icon btn-sm quick-entry-attach-button"
data-testid="quick-entry-attach"
onClick={() => fileInputRef.current?.click()}
aria-label={attachLabel}
title={attachLabel}
>
<Paperclip size={12} aria-hidden="true" />
{pendingImages.length > 0 && (
<span className="quick-entry-attach-count" aria-hidden="true">{pendingImages.length}</span>
)}
</button>
<button
type="button"
className={`btn btn-sm ${isFastMode ? "btn-primary" : ""}`}
onClick={toggleFastMode}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={isFastMode}
data-testid="quick-entry-fast-toggle"
title={t("tasks.toggleFastMode", "Toggle fast execution mode")}
>
{t("tasks.fast", "Fast")}
</button>
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSaveClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="quick-entry-save"
title={t("tasks.createTaskTitle", "Create task")}
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.save", "Save")}
</button>
</div>
</div> </div>
)} )}

View File

@@ -1456,6 +1456,63 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
outline-offset: 1px; 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 */ /* Delete button - visible on hover for triage column */
.card-delete-btn { .card-delete-btn {
display: flex; display: flex;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react"; import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
import { createPortal } from "react-dom"; 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 type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
import { import {
DEFAULT_PLANNER_OVERSIGHT_LEVEL, DEFAULT_PLANNER_OVERSIGHT_LEVEL,
@@ -915,6 +915,13 @@ function TaskCardComponent({
const touchOpenHandledRef = useRef(false); const touchOpenHandledRef = useRef(false);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(null);
const sendBackRef = useRef<HTMLDivElement>(null); const sendBackRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId); const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
@@ -2442,6 +2449,26 @@ function TaskCardComponent({
clearLongPressTimer(); clearLongPressTimer();
}, [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<HTMLButtonElement>) => {
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: 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. 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; if (!contextMenuPosition) return;
const handleDocumentPointerDown = (event: PointerEvent) => { const handleDocumentPointerDown = (event: PointerEvent) => {
if (contextMenuRef.current?.contains(event.target as Node)) return; 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(); closeContextMenu();
}; };
const handleDocumentKeyDown = (event: KeyboardEvent) => { const handleDocumentKeyDown = (event: KeyboardEvent) => {
@@ -3119,6 +3149,29 @@ function TaskCardComponent({
{task.size} {task.size}
</span> </span>
)} )}
{/*
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 && (
<button
ref={menuButtonRef}
type="button"
className="card-menu-btn"
onClick={handleMenuButtonClick}
title={t("tasks.taskActions", "Task actions")}
aria-label={t("tasks.taskActions", "Task actions")}
aria-haspopup="menu"
aria-expanded={contextMenuPosition != null}
data-testid={`card-menu-btn-${task.id}`}
>
<MoreHorizontal size={14} />
</button>
)}
</div> </div>
</div> </div>
{showStalledReview && stalledReview && ( {showStalledReview && stalledReview && (
@@ -3590,6 +3643,8 @@ const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
const TOUCH_CONTEXT_MENU_DELAY_MS = 550; // milliseconds const TOUCH_CONTEXT_MENU_DELAY_MS = 550; // milliseconds
const CONTEXT_MENU_VIEWPORT_MARGIN = 8; const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
const KEYBOARD_CONTEXT_MENU_OFFSET = 32; 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; const MAX_TITLE_LENGTH = 140;
function truncate(s: string | undefined, max: number): string { function truncate(s: string | undefined, max: number): string {

View File

@@ -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 = [ 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"], ["Priority", "quick-entry-priority-button"],
["Subtask", "subtask-button"], ["Subtask", "subtask-button"],
["Deps", "quick-entry-deps"], ["Deps", "quick-entry-deps"],
["Models", "quick-entry-models"], ["Models", "quick-entry-models"],
["Node", "quick-entry-node-button"], ["Node", "quick-entry-node-button"],
["Agent", "quick-entry-agent-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; ] as const;
describe("QuickEntryBox", () => { describe("QuickEntryBox", () => {
@@ -797,7 +803,9 @@ describe("QuickEntryBox", () => {
] as const; ] as const;
const allActionButtons = QUICK_ENTRY_ACTION_BUTTONS; 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() { function getActionButtonTestIdsInDomOrder() {
const actionsContainer = screen.getByTestId("quick-entry-actions"); 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({}); renderQuickEntryBox({});
expandQuickEntry(); expandQuickEntry();
const actionButtonTestIds = getActionButtonTestIdsInDomOrder(); const actionButtonTestIds = getActionButtonTestIdsInDomOrder();
expect(actionButtonTestIds.slice(0, 2)).toEqual(["quick-entry-save", "quick-entry-attach"]); expect(actionButtonTestIds.slice(-3)).toEqual(["quick-entry-attach", "quick-entry-fast-toggle", "quick-entry-save"]);
expect(actionButtonTestIds.indexOf("quick-entry-attach")).toBe(actionButtonTestIds.indexOf("quick-entry-save") + 1);
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", () => { it("action buttons appear in correct DOM order after reorder", () => {

View File

@@ -43,6 +43,8 @@ vi.mock("lucide-react", () => ({
// FN-7592: the overseer badge now renders an icon child instead of a text label, // 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. // so tests must see a real SVG (like Zap) rather than a no-op render.
Eye: () => <svg data-testid="icon-eye" />, Eye: () => <svg data-testid="icon-eye" />,
// FNXC:TaskCardMenu 2026-07-10-12:00: visible ⋯ card-actions button icon.
MoreHorizontal: () => <svg data-testid="icon-more-horizontal" />,
})); }));
vi.mock("../ProviderIcon", () => ({ 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(
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPauseTask={onPauseTask}
/>,
);
// 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 () => { it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry(); const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();