FN-7352: route completed-task refine menus to detail

Completed task context menus now open the existing Task Detail refinement composer.\n\n- Add one-shot detail initial actions so Refine can reopen the composer from board and list menus.\n- Wire Board, List, Column, and grouped task cards to pass Refine through Task Detail instead of hiding it.\n- Cover board/list refine menu behavior with dashboard tests and document the completed-task flow.\n\nFiles changed:\n .changeset/fn-7352-done-context-refine.md          |  7 +++\n docs/dashboard-guide.md                            |  9 ++--\n packages/dashboard/app/components/AppModals.tsx    |  9 +++-\n packages/dashboard/app/components/Board.tsx        |  7 ++-\n packages/dashboard/app/components/Column.tsx       |  5 +-\n packages/dashboard/app/components/ListView.tsx     |  7 +--\n packages/dashboard/app/components/TaskCard.tsx     | 12 +++--\n .../dashboard/app/components/TaskDetailModal.tsx   | 13 +++++\n .../dashboard/app/components/WorktreeGroup.tsx     |  4 ++\n .../app/components/__tests__/ListView.test.tsx     | 62 +++++++++++++++++++++-\n .../app/components/__tests__/TaskCard.test.tsx     | 43 +++++++++++++++\n .../TaskDetailModal.definition-actions.test.tsx    | 18 +++++++\n .../app/components/dashboard/MainContent.tsx       |  2 +\n .../dashboard/app/components/dashboard/types.ts    |  4 +-\n packages/dashboard/app/hooks/useModalManager.ts    | 28 ++++++++--\n 15 files changed, 208 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7352

Fusion-Task-Lineage: 3f1107a6-fdac-4430-ab8d-50096c36cc3c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 01:09:38 -07:00
parent d8f3cc64ae
commit d448603e71
15 changed files with 208 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show Refine in completed task card and list context menus.
category: fix
dev: Routes done/custom-complete Board and List context-menu Refine actions into the existing Task Detail refinement composer.

View File

@@ -139,9 +139,9 @@ Features:
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
- Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context.
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Actions that require additional detail-only UI, such as opening the refinement feedback modal, remain available from task detail.
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
FNXC:BoardCardActions 2026-06-30-13:12: Card context menus must not label an action as Refine unless the card surface can open the real task-detail refinement feedback modal. Keep refinement documented as detail-only until a proper modal/deep-link callback is wired. -->
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call. -->
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
@@ -174,9 +174,10 @@ Features:
- Bulk selection + batch model updates
- Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management.
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior.
- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior, including **Refine** for completed tasks.
<!-- FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points. -->
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow. -->
![List view](./screenshots/list-view.png)

View File

@@ -247,13 +247,15 @@ export function AppModals({
(
task: Parameters<typeof modalManager.openDetailTask>[0],
tab?: Parameters<typeof modalManager.openDetailTask>[1],
options?: Parameters<typeof modalManager.openDetailTask>[2],
) => {
const previousDetailTask = modalManager.detailTask;
const previousDetailTab = modalManager.detailTaskInitialTab;
const previousDetailOrigin = modalManager.detailTaskOrigin;
const previousDetailAction = modalManager.detailTaskInitialAction;
const previousNavClose = detailNavCloseRef.current;
modalManager.openDetailTask(task, tab);
modalManager.openDetailTask(task, tab, options);
const closeFromHistory = () => {
if (detailNavCloseRef.current === closeFromHistory) {
detailNavCloseRef.current = previousNavClose;
@@ -262,7 +264,9 @@ export function AppModals({
modalManager.openDetailTask(
previousDetailTask,
previousDetailTab,
previousDetailOrigin ? { origin: previousDetailOrigin } : undefined,
previousDetailOrigin || previousDetailAction
? { origin: previousDetailOrigin ?? undefined, initialAction: previousDetailAction?.action }
: undefined,
);
return;
}
@@ -332,6 +336,7 @@ export function AppModals({
taskDetailChatFirst={settings.taskDetailChatFirst}
onOpenWorkflowEditor={() => modalManager.openWorkflowEditor()}
initialTab={modalManager.detailTaskInitialTab}
initialAction={modalManager.detailTaskInitialAction}
/>
</ModalErrorBoundary>
)}

View File

@@ -36,6 +36,7 @@ interface BoardProps {
onDuplicateTask?: (id: string) => Promise<Task>;
onMergeTask?: (id: string) => Promise<MergeResult>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenRefine?: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
@@ -155,7 +156,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
);
}
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
@@ -832,6 +833,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
@@ -912,6 +914,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
@@ -966,6 +969,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
@@ -1016,6 +1020,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}

View File

@@ -101,6 +101,7 @@ interface ColumnProps {
onDuplicateTask?: (id: string) => Promise<Task>;
onMergeTask?: (id: string) => Promise<MergeResult>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenRefine?: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
@@ -194,7 +195,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -805,6 +806,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
queuedTasks={group.queuedTasks}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onMoveTask={onMoveTask}
addToast={addToast}
globalPaused={globalPaused}
@@ -844,6 +846,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}

View File

@@ -28,6 +28,7 @@ import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
import type { DetailTaskOpenOptions } from "../hooks/useModalManager";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -222,7 +223,7 @@ interface ListViewProps {
onMergeTask: (id: string) => Promise<MergeResult>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => void;
onOpenDetail: (task: Task | TaskDetail, options?: DetailTaskOpenOptions) => void;
/*
FNXC:FloatingWindow 2026-06-22-20:45:
onPopOut pops the split-pane task detail into a movable, resizable, non-blocking FloatingWindow managed at App level. Wired to the Maximize2 "Pop out" button in TaskDetailContent's header.
@@ -1586,7 +1587,7 @@ export function ListView({
addToast(getErrorMessage(err), "error");
}
} : undefined,
onOpenRefine: undefined,
onOpenRefine: () => onOpenDetail(task, { origin: isMobile ? "list-mobile" : undefined, initialAction: "refine" }),
onRespecify: async () => {
const shouldRebuild = await confirm({
title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"),
@@ -1660,7 +1661,7 @@ export function ListView({
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextMove, handleListTaskArchive, handleListTaskDelete, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, projectId, t]);
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextMove, handleListTaskArchive, handleListTaskDelete, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, projectId, t]);
const contextMenuActions = useMemo(
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),

View File

@@ -377,6 +377,7 @@ interface TaskCardProps {
projectId?: string;
queued?: boolean;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenRefine?: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
@@ -615,6 +616,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onDuplicateTask === next.onDuplicateTask &&
previous.onMergeTask === next.onMergeTask &&
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
previous.onOpenRefine === next.onOpenRefine &&
previous.onOpenMission === next.onOpenMission &&
previous.onMoveTask === next.onMoveTask &&
previous.onPromote === next.onPromote &&
@@ -717,6 +719,7 @@ function TaskCardComponent({
projectId,
queued,
onOpenDetail,
onOpenRefine,
onOpenGroupModal,
addToast,
globalPaused,
@@ -1865,7 +1868,7 @@ function TaskCardComponent({
Board cards expose the same lifecycle actions as Task Detail from right-click, keyboard context menu, and touch long-press so operators can act without opening detail. Dock/plugin TaskCard users stay unchanged because the menu only mounts when Board/List owners pass action handlers.
FNXC:BoardCardActions 2026-06-30-00:30:
Context-menu moves reuse the Task Detail preserve/reset progress confirmation path before moving back to Todo or Triage, because those transitions can reset completed steps. Refine stays hidden outside Task Detail until card surfaces can open the actual refine modal, while manual PR entries open the existing PR flows instead of silently dropping unavailable actions.
Context-menu moves reuse the Task Detail preserve/reset progress confirmation path before moving back to Todo or Triage, because those transitions can reset completed steps. Refine opens the existing Task Detail feedback modal from card right-click/long-press when the board host supplies that route, while manual PR entries open the existing PR flows instead of silently dropping unavailable actions.
FNXC:BoardCardActions 2026-06-30-00:42:
Board context menus must receive the project merge strategy, not infer pull-request mode from existing PR data, so manual PR projects show Start PR Review before the PR entity is created.
@@ -1896,7 +1899,7 @@ function TaskCardComponent({
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
onDelete: onDeleteTask ? handleTaskActionDelete : undefined,
onDuplicate: onDuplicateTask ? handleTaskActionDuplicate : undefined,
onOpenRefine: undefined,
onOpenRefine: onOpenRefine ? () => onOpenRefine(task) : undefined,
onRespecify: handleTaskActionRespecify,
onRetry: onRetryTask ? handleTaskActionRetry : undefined,
onReset: onResetTask ? handleTaskActionReset : undefined,
@@ -1930,6 +1933,7 @@ function TaskCardComponent({
onDeleteTask,
onMergeTask,
onOpenDetail,
onOpenRefine,
onPauseTask,
onUnpauseTask,
task,
@@ -1938,7 +1942,7 @@ function TaskCardComponent({
task.prInfo,
]);
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask) {
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine) {
return [];
}
const actions = [...taskActionMenuModel.actions];
@@ -1961,7 +1965,7 @@ function TaskCardComponent({
}
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionUnarchive, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPauseTask, onResetTask, onRetryTask, onUnarchiveTask, onUnpauseTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionUnarchive, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onUnarchiveTask, onUnpauseTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
const hasContextMenuActions = contextMenuActions.length > 0;
const closeContextMenu = useCallback(() => {

View File

@@ -68,6 +68,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu";
import { useFileBrowser } from "../context/FileBrowserContext";
import type { DetailTaskInitialActionRequest } from "../hooks/useModalManager";
const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
@@ -326,6 +327,8 @@ export interface TaskDetailModalProps {
onOpenWorkflowEditor?: () => void;
/** Open the modal with this tab active instead of the default done-aware landing view. */
initialTab?: TabId;
/** One-shot action the detail surface should perform after opening. */
initialAction?: DetailTaskInitialActionRequest | null;
/** Mobile-only header affordance mode. */
mobileHeaderMode?: "close" | "back";
/** Project setting: true restores Chat-first tab order/default; false or missing uses Activity-first. */
@@ -542,6 +545,7 @@ export function TaskDetailContent({
* The Activity tab is still addressed as `chat` internally so existing callers and deep links do not break; the visible Chat tab uses `planner-chat` and only becomes the omitted non-done default when taskDetailChatFirst is true.
*/
initialTab,
initialAction,
taskDetailChatFirst = false,
mobileHeaderMode = "close",
embedded = false,
@@ -2277,6 +2281,15 @@ export function TaskDetailContent({
setRefineFeedback("");
}, []);
useEffect(() => {
if (initialAction?.action !== "refine") return;
/*
FNXC:DoneTaskRefine 2026-07-01-00:00:
Done-task card/list right-click and long-press menus route Refine through Task Detail so operators get the existing feedback composer, validation, toasts, and refineTask submission instead of a dead menu item or an immediate API call.
*/
handleOpenRefineModal();
}, [handleOpenRefineModal, initialAction?.action, initialAction?.requestId]);
// Helper to close dropdown menus after action
const closeMenus = useCallback(() => {
setShowMoveMenu(false);

View File

@@ -15,6 +15,7 @@ interface WorktreeGroupProps {
allTasks?: Task[];
projectId?: string;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenRefine?: (task: Task | TaskDetail) => void;
onMoveTask?: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
@@ -67,6 +68,7 @@ function WorktreeGroupComponent({
allTasks,
projectId,
onOpenDetail,
onOpenRefine,
onMoveTask,
addToast,
globalPaused,
@@ -118,6 +120,7 @@ function WorktreeGroupComponent({
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onMoveTask={onMoveTask}
taskColumnFlags={getTaskColumnFlags(task)}
taskMoveColumns={getTaskContextMenuColumns(task)}
@@ -153,6 +156,7 @@ function WorktreeGroupComponent({
projectId={projectId}
queued
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onMoveTask={onMoveTask}
taskColumnFlags={getTaskColumnFlags(task)}
taskMoveColumns={getTaskContextMenuColumns(task)}

View File

@@ -673,16 +673,19 @@ describe("ListView", () => {
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-003"]') as HTMLElement, { clientX: 40, clientY: 50 });
expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Refine" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeInTheDocument();
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-006"]') as HTMLElement, { clientX: 40, clientY: 50 });
expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument();
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-004"]') as HTMLElement, { clientX: 40, clientY: 50 });
expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Refine" })).toBeInTheDocument();
expect(onOpenDetail).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-004" }), { origin: undefined, initialAction: "refine" });
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-004"]') as HTMLElement, { clientX: 40, clientY: 50 });
expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument();
@@ -696,6 +699,7 @@ describe("ListView", () => {
reviewRow.focus();
fireEvent.keyDown(reviewRow, { key: "ContextMenu" });
expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Refine" })).toBeInTheDocument();
mockConfirm.mockResolvedValueOnce(true);
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-007"]') as HTMLElement, { clientX: 40, clientY: 50 });
@@ -710,6 +714,39 @@ describe("ListView", () => {
viewportSpy.mockRestore();
});
it("shows refine for custom workflow complete-column rows", async () => {
const viewportSpy = mockDesktopViewport();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
{ id: "cold-storage", name: "Cold Storage", flags: { archived: true } },
],
},
],
taskWorkflowIds: { "FN-012": "wf-custom" },
});
const onOpenDetail = vi.fn();
const tasks = [createMockTask({ id: "FN-012", title: "Custom complete", column: "complete" as any, status: "done" })];
renderListView({ tasks, onOpenDetail, workflowColumnsEnabled: true, settingsLoaded: true });
await screen.findByText("Custom complete");
const row = document.querySelector('.list-row[data-id="FN-012"]') as HTMLElement;
expect(row).toBeInTheDocument();
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-012" }), { origin: undefined, initialAction: "refine" });
viewportSpy.mockRestore();
});
it("matches detail PR review labels from list context menus before and during PR automation", () => {
const viewportSpy = mockDesktopViewport();
const tasks = [
@@ -791,6 +828,27 @@ describe("ListView", () => {
vi.useRealTimers();
});
it("opens refine from a mobile done-card long-press", () => {
vi.useFakeTimers();
const viewportSpy = mockMobileViewport();
const onOpenDetail = vi.fn();
const tasks = [createMockTask({ id: "FN-011", title: "Mobile done", column: "done", status: "done" })];
renderListView({ tasks, onOpenDetail, onDeleteTask: vi.fn(async () => createMockTask()) });
const card = document.querySelector('.list-card[data-id="FN-011"]') as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 });
act(() => {
vi.advanceTimersByTime(550);
});
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-011" }), { origin: "list-mobile", initialAction: "refine" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
viewportSpy.mockRestore();
vi.useRealTimers();
});
it("exposes view options controls on mobile", () => {
const viewportSpy = mockMobileViewport();
localStorage.setItem(scopedStorageKey("kb-dashboard-hide-done"), "false");

View File

@@ -233,6 +233,49 @@ describe("TaskCard", () => {
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("shows refine for a done card context menu and routes to the refinement opener", () => {
const onOpenDetail = vi.fn();
const onOpenRefine = vi.fn();
render(
<TaskCard
task={makeTask({ column: "done", status: "done" as any })}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
addToast={noop}
onDeleteTask={vi.fn()}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" }));
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("shows refine for custom complete cards on touch long-press", () => {
vi.useFakeTimers();
const onOpenRefine = vi.fn();
render(
<TaskCard
task={makeTask({ column: "complete" as any, status: "done" as any })}
taskColumnFlags={{ complete: true }}
onOpenDetail={noop}
onOpenRefine={onOpenRefine}
addToast={noop}
onDeleteTask={vi.fn()}
/>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
act(() => vi.advanceTimersByTime(550));
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" }));
});
it("confirms preserving progress before moving from the board context menu", async () => {
const onMoveTask = vi.fn(async () => makeTask({ column: "todo" }));
mockConfirm.mockResolvedValueOnce(true);

View File

@@ -1341,6 +1341,24 @@ describe("TaskDetailModal", () => {
expect(refineTask).not.toHaveBeenCalled();
});
it("opens the refine composer from an initial action request", () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
initialAction={{ action: "refine", requestId: 1 }}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Refine", { selector: "h3" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeInTheDocument();
});
it("calls refineTask and closes modal on successful submission", async () => {
const { refineTask } = await import("../../api");
vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "triage" } as Task);

View File

@@ -689,6 +689,7 @@ export function MainContent({
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openBoardTaskDetail}
onOpenRefine={(task) => openDetailTask(task, undefined, { initialAction: "refine" })}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
@@ -791,6 +792,7 @@ export function MainContent({
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openBoardTaskDetail}
onOpenRefine={(task) => openDetailTask(task, undefined, { initialAction: "refine" })}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}

View File

@@ -29,7 +29,7 @@ import type {
ProjectInfoWithSource,
} from "../../api";
import type { FusionShellApi } from "../../types/native-shell";
import type { DetailTaskOrigin, DetailTaskTab, ModalManager } from "../../hooks/useModalManager";
import type { DetailTaskOpenOptions, DetailTaskTab, ModalManager } from "../../hooks/useModalManager";
import type { PluginTaskView, TaskView, ViewMode } from "../../hooks/useViewState";
import type { ToastType } from "../../hooks/useToast";
import type { QuickChatButtonMode } from "../../hooks/useAppSettings";
@@ -106,7 +106,7 @@ export interface MainContentProps {
openDetailTask: (
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
options?: DetailTaskOpenOptions,
) => void;
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
prAuthAvailable: boolean;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Task, TaskDetail } from "@fusion/core";
import type { SectionId } from "../components/SettingsModal";
@@ -22,6 +22,17 @@ export type DetailTaskTab =
| "retries";
export type DetailTaskOrigin = "list-mobile";
export type DetailTaskInitialAction = "refine";
export interface DetailTaskOpenOptions {
origin?: DetailTaskOrigin;
initialAction?: DetailTaskInitialAction;
}
export interface DetailTaskInitialActionRequest {
action: DetailTaskInitialAction;
requestId: number;
}
interface UseModalManagerOptions {
projectId?: string;
@@ -46,6 +57,7 @@ export interface ModalManager {
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab | undefined;
detailTaskInitialAction: DetailTaskInitialActionRequest | null;
detailTaskOrigin: DetailTaskOrigin | null;
groupModalGroupId: string | null;
settingsOpen: boolean;
@@ -93,7 +105,7 @@ export interface ModalManager {
openDetailTask: (
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
options?: DetailTaskOpenOptions,
) => void;
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
updateDetailTask: (updated: Partial<TaskDetail>) => void;
@@ -183,6 +195,12 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
* Store omitted task-detail tabs as `undefined` so done tasks can resolve the implicit landing tab to Summary without stealing explicit Chat requests.
*/
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab | undefined>(undefined);
/*
FNXC:DoneTaskRefine 2026-07-01-00:00:
Done-task card/list context menus must open the existing Task Detail refinement modal after right-click or long-press. Store refinement as a one-shot action request with a monotonically increasing id so selecting Refine again for an already-open task reopens the composer without duplicating API/form logic outside TaskDetailContent.
*/
const [detailTaskInitialAction, setDetailTaskInitialAction] = useState<DetailTaskInitialActionRequest | null>(null);
const detailTaskInitialActionRequestIdRef = useRef(0);
const [detailTaskOrigin, setDetailTaskOrigin] = useState<DetailTaskOrigin | null>(null);
const [groupModalGroupId, setGroupModalGroupId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -309,15 +327,17 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openDetailTask = useCallback((
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
options?: DetailTaskOpenOptions,
) => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
setDetailTaskInitialAction(options?.initialAction ? { action: options.initialAction, requestId: detailTaskInitialActionRequestIdRef.current += 1 } : null);
setDetailTaskOrigin(options?.origin ?? null);
}, []);
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
setDetailTask(task);
setDetailTaskInitialTab("changes");
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
}, []);
const updateDetailTask = useCallback((updated: Partial<TaskDetail>) => {
@@ -331,6 +351,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
}, []);
const closeDetailTask = useCallback(() => {
setDetailTask(null);
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
}, []);
@@ -480,6 +501,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
subtaskWorkflowId,
detailTask,
detailTaskInitialTab,
detailTaskInitialAction,
detailTaskOrigin,
groupModalGroupId,
settingsOpen,