diff --git a/.changeset/fn-7352-done-context-refine.md b/.changeset/fn-7352-done-context-refine.md new file mode 100644 index 0000000000..d0f590c12a --- /dev/null +++ b/.changeset/fn-7352-done-context-refine.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b9ced851c9..b7749dabca 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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: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. --> @@ -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-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) diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index b2a8bd49e0..5bfeff9288 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -247,13 +247,15 @@ export function AppModals({ ( task: Parameters[0], tab?: Parameters[1], + options?: Parameters[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} /> )} diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 6cde058609..b23fe1bd2b 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -36,6 +36,7 @@ interface BoardProps { onDuplicateTask?: (id: string) => Promise; onMergeTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; + onOpenRefine?: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; @@ -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} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 16fe956eb8..10fd5c9201 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -101,6 +101,7 @@ interface ColumnProps { onDuplicateTask?: (id: string) => Promise; onMergeTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; + onOpenRefine?: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; @@ -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} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 86dabab3c0..e6179c6a84 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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 = { triage: "var(--triage)", @@ -222,7 +223,7 @@ interface ListViewProps { onMergeTask: (id: string) => Promise; onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; - 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) : []), diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 8e8a27af8a..947e2cf3ff 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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(() => { - 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(() => { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 119a34cc05..b16752a917 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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); diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index e229a03a5e..a47183ab6e 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -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; 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)} diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index fc96c655b5..b4ccbfc373 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -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"); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index cb5d9dafdb..699d99dc81 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index 86a90ba492..05e023ba02 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -1341,6 +1341,24 @@ describe("TaskDetailModal", () => { expect(refineTask).not.toHaveBeenCalled(); }); + it("opens the refine composer from an initial action request", () => { + render( + , + ); + + 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); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index a96d9624af..ff6e8415d4 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 50b194e4ce..6e4d4f2ee0 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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; diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 8629aa1bb3..82693fffba 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -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) => 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(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(null); + const detailTaskInitialActionRequestIdRef = useRef(0); const [detailTaskOrigin, setDetailTaskOrigin] = useState(null); const [groupModalGroupId, setGroupModalGroupId] = useState(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) => { @@ -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,