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:
7
.changeset/board-composer-menu.md
Normal file
7
.changeset/board-composer-menu.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user