diff --git a/.changeset/fn-7372-enable-github-tracking-context-menu.md b/.changeset/fn-7372-enable-github-tracking-context-menu.md new file mode 100644 index 0000000000..33e9fd19cd --- /dev/null +++ b/.changeset/fn-7372-enable-github-tracking-context-menu.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add Enable GitHub tracking to Board and List task context menus. +category: feature +dev: Reuses the existing task PATCH GitHub tracking flow from shared card/list context menu actions. diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 10fd5c9201..23a4463163 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -113,7 +113,7 @@ interface ColumnProps { globalPaused?: boolean; onUpdateTask?: ( id: string, - updates: { title?: string; description?: string; dependencies?: string[] } + updates: { title?: string; description?: string; dependencies?: string[]; githubTracking?: { enabled?: boolean } } ) => Promise; onRetryTask?: (id: string) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index fd9ab55839..91ada2b550 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -9,7 +9,7 @@ import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; -import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus } from "../api"; +import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus, updateTask } from "../api"; import { TaskDetailContent } from "./TaskDetailModal"; import { PrCreateModal } from "./PrCreateModal"; import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api"; @@ -1579,6 +1579,21 @@ export function ListView({ } }, [addToast, projectId, t]); + /* + FNXC:GitHubTracking 2026-07-01-00:00: + List row/card context menus use the same PATCH helper as Task Detail to enable GitHub tracking, then push the returned task into parent and split-detail snapshots. This keeps desktop right-click and mobile long-press menus stateful without changing row selection/open behavior. + */ + const handleListContextEnableGithubTracking = useCallback(async (task: Task) => { + try { + const updatedTask = await updateTask(task.id, { githubTracking: { enabled: true } }, projectId); + onTasksUpdated?.([updatedTask]); + setSelectedTaskSnapshot((previous) => previous?.id === updatedTask.id ? ({ ...previous, ...updatedTask, githubTracking: updatedTask.githubTracking } as Task | TaskDetail) : previous); + addToast(t("taskDetail.githubTracking.issueCreationRequested", "Requested GitHub tracking issue creation"), "info"); + } catch (err) { + addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error"); + } + }, [addToast, onTasksUpdated, projectId, t]); + const handleListPrCreated = useCallback((task: Task, prInfo: PrInfo) => { const nextPrInfos = [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : [])), prInfo]; onTasksUpdated?.([{ ...task, prInfo: nextPrInfos[0] ?? prInfo, prInfos: nextPrInfos }]); @@ -1682,6 +1697,7 @@ export function ListView({ } : undefined, onStartPrReview: () => setPrCreateState({ task }), onCheckPrStatus: task.prInfo ? () => void handleListContextCheckPrStatus(task) : undefined, + onEnableGithubTracking: onTasksUpdated ? () => void handleListContextEnableGithubTracking(task) : undefined, }); const actions = [...model.actions]; @@ -1699,7 +1715,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, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, projectId, t]); + }, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onTasksUpdated, 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 dae57fb25e..5f37035a0a 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -384,7 +384,7 @@ interface TaskCardProps { globalPaused?: boolean; onUpdateTask?: ( id: string, - updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean } + updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } } ) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; @@ -711,7 +711,9 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo */ return areTaskBadgeInfosEqual(pr, nextPr); }) && - areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) + areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) && + // FNXC:GitHubTracking 2026-07-01-00:00: Context-menu tracking actions depend on githubTracking.enabled, so memoized cards must repaint when a PATCH enables tracking and remove the now-ineligible menu item. + JSON.stringify(previousTask.githubTracking ?? null) === JSON.stringify(nextTask.githubTracking ?? null) ); } @@ -1879,7 +1881,19 @@ function TaskCardComponent({ FNXC:BoardCardActions 2026-06-30-13:02: Manual pull-request projects need a distinct Start PR Review callback from direct Merge & Close so context menus open PrCreateModal instead of calling the merge endpoint. + + FNXC:GitHubTracking 2026-07-01-00:00: + Board card context menus may enable GitHub tracking only when the board host supplies onUpdateTask, because that callback owns the existing PATCH flow plus optimistic/local task refresh. This keeps right-click, keyboard context menu, and touch long-press actions from becoming dead menu items in dock/plugin card embeddings. */ + const handleTaskActionEnableGithubTracking = useCallback(async () => { + if (!onUpdateTask) return; + try { + await onUpdateTask(task.id, { githubTracking: { enabled: true } }); + addToast(t("taskDetail.githubTracking.issueCreationRequested", "Requested GitHub tracking issue creation"), "info"); + } catch (err) { + addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error"); + } + }, [addToast, onUpdateTask, task.id, t]); const taskActionColumnLabel = useCallback((column: ColumnId) => { return taskMoveColumns?.find((candidate) => candidate.id === column)?.label ?? columnLabel(column); }, [columnLabel, taskMoveColumns]); @@ -1908,6 +1922,7 @@ function TaskCardComponent({ onMerge: onMergeTask ? handleTaskActionMerge : undefined, onStartPrReview: () => setIsPrCreateOpen(true), onCheckPrStatus: task.prInfo ? handleTaskActionCheckPrStatus : undefined, + onEnableGithubTracking: onUpdateTask ? handleTaskActionEnableGithubTracking : undefined, }), [ task, t, @@ -1923,6 +1938,7 @@ function TaskCardComponent({ handleTaskActionArchive, handleTaskActionCheckPrStatus, handleTaskActionDelete, + handleTaskActionEnableGithubTracking, handleTaskActionDuplicate, handleTaskActionMerge, handleTaskActionReset, @@ -1933,6 +1949,7 @@ function TaskCardComponent({ isPaused, onDeleteTask, onMergeTask, + onUpdateTask, onOpenDetail, onOpenRefine, onPauseTask, @@ -1943,7 +1960,7 @@ function TaskCardComponent({ task.prInfo, ]); const contextMenuActions = useMemo(() => { - if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine) { + if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine && !onUpdateTask) { return []; } const actions = [...taskActionMenuModel.actions]; @@ -1966,7 +1983,7 @@ function TaskCardComponent({ } } return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect)); - }, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionUnarchive, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onOpenRefine, 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, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]); const hasContextMenuActions = contextMenuActions.length > 0; const closeContextMenu = useCallback(() => { @@ -1975,7 +1992,7 @@ function TaskCardComponent({ useEffect(() => { closeContextMenu(); - }, [closeContextMenu, task.column, task.id]); + }, [closeContextMenu, task.column, task.githubTracking?.enabled, task.id]); const clearLongPressTimer = useCallback(() => { if (longPressTimerRef.current) { diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index 795fabe874..7a8af9fab3 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -77,6 +77,7 @@ export interface BuildTaskActionMenuModelOptions { onMerge?: () => void; onStartPrReview?: () => void; onCheckPrStatus?: () => void; + onEnableGithubTracking?: () => void; } export function getTaskPrAutomationLabel(t: TFunction<"app">, status?: string): string | undefined { @@ -230,6 +231,18 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry }); } + /* + FNXC:GitHubTracking 2026-07-01-00:00: + Board and List task menus mirror Task Detail's GitHub tracking enablement with one shared descriptor. Only hosts that can PATCH and refresh local task state inject the callback, so untracked tasks get a working shortcut and already-enabled/linked tasks never leave an empty disabled shell. + */ + if (options.onEnableGithubTracking && task.githubTracking?.enabled !== true) { + actions.push({ + id: "enable-github-tracking", + label: t("taskDetail.githubTracking.enableCheckboxLabel", "Enable GitHub tracking"), + onSelect: options.onEnableGithubTracking, + }); + } + if (hasResetHandler && isMutableLiveColumn(task.column, currentColumnFlags)) { actions.push({ id: "reset", label: t("taskDetail.reset.btn", "Reset"), tone: "danger", onSelect: options.onReset }); } @@ -255,7 +268,8 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption task.status === "awaiting-approval" || canRetryTask || isTaskPaused || - hasAssignedAgent, + hasAssignedAgent || + Boolean(options.onEnableGithubTracking && task.githubTracking?.enabled !== true), isTaskPaused, }; } diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 8873021931..d6406df390 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -29,6 +29,7 @@ vi.mock("../../api", () => ({ fetchBoardWorkflows: vi.fn(() => new Promise(() => {})), rebuildTaskSpec: vi.fn().mockResolvedValue({}), refreshPrStatus: vi.fn().mockResolvedValue({}), + updateTask: vi.fn(), api: vi.fn().mockResolvedValue({ sessions: [] }), })); @@ -200,7 +201,7 @@ vi.mock("../TaskDetailModal", () => ({ ), })); -import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, refreshPrStatus } from "../../api"; +import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, refreshPrStatus, updateTask } from "../../api"; const mockConfirm = vi.fn(); const mockConfirmWithChoice = vi.fn(); @@ -717,6 +718,54 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); + it("enables GitHub tracking from desktop and mobile list context menus without selecting rows", async () => { + const desktopViewportSpy = mockDesktopViewport(); + const onOpenDetail = vi.fn(); + const onTasksUpdated = vi.fn(); + vi.mocked(updateTask).mockResolvedValueOnce(createMockTask({ id: "FN-020", title: "Desktop tracking", column: "todo", githubTracking: { enabled: true } as any })); + const desktopTasks = [createMockTask({ id: "FN-020", title: "Desktop tracking", column: "todo", githubTracking: undefined })]; + const desktopRender = renderListView({ tasks: desktopTasks, onOpenDetail, onTasksUpdated }); + + const desktopRow = document.querySelector('.list-row[data-id="FN-020"]') as HTMLElement; + fireEvent.contextMenu(desktopRow, { clientX: 40, clientY: 50 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Enable GitHub tracking" })); + + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("FN-020", { githubTracking: { enabled: true } }, TEST_PROJECT_ID)); + expect(onTasksUpdated).toHaveBeenCalledWith([expect.objectContaining({ id: "FN-020", githubTracking: { enabled: true } })]); + expect(mockAddToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info"); + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(desktopRow).not.toHaveClass("list-row--selected"); + desktopRender.unmount(); + desktopViewportSpy.mockRestore(); + + vi.mocked(updateTask).mockResolvedValueOnce(createMockTask({ id: "FN-021", title: "Mobile tracking", column: "todo", githubTracking: { enabled: true } as any })); + vi.useFakeTimers(); + const mobileViewportSpy = mockMobileViewport(); + const mobileOnOpenDetail = vi.fn(); + const mobileOnTasksUpdated = vi.fn(); + renderListView({ tasks: [createMockTask({ id: "FN-021", title: "Mobile tracking", column: "todo", githubTracking: { enabled: false } as any })], onOpenDetail: mobileOnOpenDetail, onTasksUpdated: mobileOnTasksUpdated }); + + const mobileCard = document.querySelector('.list-card[data-id="FN-021"]') as HTMLElement; + fireEvent.pointerDown(mobileCard, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 }); + act(() => { + vi.advanceTimersByTime(550); + }); + fireEvent.pointerUp(mobileCard, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 }); + fireEvent.click(mobileCard); + expect(mobileOnOpenDetail).not.toHaveBeenCalled(); + + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Enable GitHub tracking" }), { pointerType: "touch", pointerId: 2 }); + await act(async () => { + await Promise.resolve(); + }); + expect(updateTask).toHaveBeenLastCalledWith("FN-021", { githubTracking: { enabled: true } }, TEST_PROJECT_ID); + expect(mobileOnTasksUpdated).toHaveBeenCalledWith([expect.objectContaining({ id: "FN-021", githubTracking: { enabled: true } })]); + expect(mobileOnOpenDetail).not.toHaveBeenCalled(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + mobileViewportSpy.mockRestore(); + vi.useRealTimers(); + }); + it("shows refine for custom workflow complete-column rows", async () => { const viewportSpy = mockDesktopViewport(); vi.mocked(fetchBoardWorkflows).mockResolvedValue({ diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index b3c0c92b64..416a9b0e5a 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -263,6 +263,47 @@ describe("TaskCard", () => { } }); + it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => { + const cleanupGeometry = mockBoardContextMenuGeometry(); + const onOpenDetail = vi.fn(); + const addToast = vi.fn(); + const onUpdateTask = vi.fn(async () => makeTask({ githubTracking: { enabled: true } as any })); + const { rerender } = render( + , + ); + + try { + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Enable GitHub tracking" })); + + await waitFor(() => expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } })); + expect(addToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + + rerender( + , + ); + await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.queryByRole("menuitem", { name: "Enable GitHub tracking" })).not.toBeInTheDocument(); + } finally { + cleanupGeometry(); + } + }); + it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index f8ebad8c3e..358dc9d784 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -48,6 +48,44 @@ describe("TaskContextMenu shared task action model", () => { expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["delete", "respecify"]); }); + it("exposes GitHub tracking enablement only for untracked tasks with a host callback", () => { + const onEnableGithubTracking = vi.fn(); + const untracked = buildTaskActionMenuModel({ + task: makeTask({ githubTracking: undefined }), + t, + columnLabel: columnLabel as any, + onEnableGithubTracking, + }); + const disabled = buildTaskActionMenuModel({ + task: makeTask({ githubTracking: { enabled: false } as any }), + t, + columnLabel: columnLabel as any, + onEnableGithubTracking, + }); + const enabled = buildTaskActionMenuModel({ + task: makeTask({ githubTracking: { enabled: true } as any }), + t, + columnLabel: columnLabel as any, + onEnableGithubTracking, + }); + const linked = buildTaskActionMenuModel({ + task: makeTask({ githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } as any }), + t, + columnLabel: columnLabel as any, + onEnableGithubTracking, + }); + const noCallback = buildTaskActionMenuModel({ task: makeTask(), t, columnLabel: columnLabel as any }); + + expect(untracked.actions.find((action) => action.id === "enable-github-tracking")?.label).toBe("Enable GitHub tracking"); + expect(disabled.actions.map((action) => action.id)).toContain("enable-github-tracking"); + expect(enabled.actions.map((action) => action.id)).not.toContain("enable-github-tracking"); + expect(linked.actions.map((action) => action.id)).not.toContain("enable-github-tracking"); + expect(noCallback.actions.map((action) => action.id)).not.toContain("enable-github-tracking"); + + untracked.actions.find((action) => action.id === "enable-github-tracking")?.onSelect?.(); + expect(onEnableGithubTracking).toHaveBeenCalledTimes(1); + }); + it("exposes pause, unpause, and paused-by-agent note with detail labels", () => { const active = buildTaskActionMenuModel({ task: makeTask(), t, columnLabel: columnLabel as any }); expect(active.actions.find((action) => action.id === "pause")?.label).toBe("Pause"); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 7752959ff1..325adde437 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -703,7 +703,7 @@ export function useTasks(options?: UseTasksOptions) { const updateTask = useCallback(async ( id: string, - updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean } + updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } } ): Promise => { const previousTask = tasksRef.current.find((t) => t.id === id); const optimisticTask = previousTask