FN-7372: add GitHub tracking context menu action

Expose GitHub tracking enablement from task context menus while reusing existing task update paths.

- Add an Enable GitHub tracking action to the shared task context menu model for untracked tasks with update-capable hosts.
- Wire board cards and list rows to PATCH githubTracking, refresh local snapshots, close stale menus, and show task-detail toasts.
- Extend context-menu/card/list tests and add a changeset for the published CLI package.

Files changed:
 .../fn-7372-enable-github-tracking-context-menu.md |  7 +++
 packages/dashboard/app/components/Column.tsx       |  2 +-
 packages/dashboard/app/components/ListView.tsx     | 20 ++++++++-
 packages/dashboard/app/components/TaskCard.tsx     | 27 +++++++++---
 .../dashboard/app/components/TaskContextMenu.tsx   | 16 ++++++-
 .../app/components/__tests__/ListView.test.tsx     | 51 +++++++++++++++++++++-
 .../app/components/__tests__/TaskCard.test.tsx     | 41 +++++++++++++++++
 .../components/__tests__/TaskContextMenu.test.tsx  | 38 ++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           |  2 +-
 9 files changed, 193 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7372

Fusion-Task-Lineage: 0d2a05fb-6152-4e29-ac2c-88dd055132ee

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 10:13:33 -07:00
parent 627c5fb0bd
commit 17505231d6
9 changed files with 193 additions and 11 deletions

View File

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

View File

@@ -113,7 +113,7 @@ interface ColumnProps {
globalPaused?: boolean; globalPaused?: boolean;
onUpdateTask?: ( onUpdateTask?: (
id: string, id: string,
updates: { title?: string; description?: string; dependencies?: string[] } updates: { title?: string; description?: string; dependencies?: string[]; githubTracking?: { enabled?: boolean } }
) => Promise<Task>; ) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>; onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;

View File

@@ -9,7 +9,7 @@ import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { useColumnLabel } from "../i18n/labels"; import { useColumnLabel } from "../i18n/labels";
import { sortTasksForDisplayColumn } from "./taskSorting"; 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 { TaskDetailContent } from "./TaskDetailModal";
import { PrCreateModal } from "./PrCreateModal"; import { PrCreateModal } from "./PrCreateModal";
import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api"; import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
@@ -1579,6 +1579,21 @@ export function ListView({
} }
}, [addToast, projectId, t]); }, [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 handleListPrCreated = useCallback((task: Task, prInfo: PrInfo) => {
const nextPrInfos = [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : [])), prInfo]; const nextPrInfos = [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : [])), prInfo];
onTasksUpdated?.([{ ...task, prInfo: nextPrInfos[0] ?? prInfo, prInfos: nextPrInfos }]); onTasksUpdated?.([{ ...task, prInfo: nextPrInfos[0] ?? prInfo, prInfos: nextPrInfos }]);
@@ -1682,6 +1697,7 @@ export function ListView({
} : undefined, } : undefined,
onStartPrReview: () => setPrCreateState({ task }), onStartPrReview: () => setPrCreateState({ task }),
onCheckPrStatus: task.prInfo ? () => void handleListContextCheckPrStatus(task) : undefined, onCheckPrStatus: task.prInfo ? () => void handleListContextCheckPrStatus(task) : undefined,
onEnableGithubTracking: onTasksUpdated ? () => void handleListContextEnableGithubTracking(task) : undefined,
}); });
const actions = [...model.actions]; 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 }); 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)); 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( const contextMenuActions = useMemo(
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []), () => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),

View File

@@ -384,7 +384,7 @@ interface TaskCardProps {
globalPaused?: boolean; globalPaused?: boolean;
onUpdateTask?: ( onUpdateTask?: (
id: string, id: string,
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean } updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }
) => Promise<Task>; ) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>; onUnarchiveTask?: (id: string) => Promise<Task>;
@@ -711,7 +711,9 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
*/ */
return areTaskBadgeInfosEqual(pr, nextPr); 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: 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. 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) => { const taskActionColumnLabel = useCallback((column: ColumnId) => {
return taskMoveColumns?.find((candidate) => candidate.id === column)?.label ?? columnLabel(column); return taskMoveColumns?.find((candidate) => candidate.id === column)?.label ?? columnLabel(column);
}, [columnLabel, taskMoveColumns]); }, [columnLabel, taskMoveColumns]);
@@ -1908,6 +1922,7 @@ function TaskCardComponent({
onMerge: onMergeTask ? handleTaskActionMerge : undefined, onMerge: onMergeTask ? handleTaskActionMerge : undefined,
onStartPrReview: () => setIsPrCreateOpen(true), onStartPrReview: () => setIsPrCreateOpen(true),
onCheckPrStatus: task.prInfo ? handleTaskActionCheckPrStatus : undefined, onCheckPrStatus: task.prInfo ? handleTaskActionCheckPrStatus : undefined,
onEnableGithubTracking: onUpdateTask ? handleTaskActionEnableGithubTracking : undefined,
}), [ }), [
task, task,
t, t,
@@ -1923,6 +1938,7 @@ function TaskCardComponent({
handleTaskActionArchive, handleTaskActionArchive,
handleTaskActionCheckPrStatus, handleTaskActionCheckPrStatus,
handleTaskActionDelete, handleTaskActionDelete,
handleTaskActionEnableGithubTracking,
handleTaskActionDuplicate, handleTaskActionDuplicate,
handleTaskActionMerge, handleTaskActionMerge,
handleTaskActionReset, handleTaskActionReset,
@@ -1933,6 +1949,7 @@ function TaskCardComponent({
isPaused, isPaused,
onDeleteTask, onDeleteTask,
onMergeTask, onMergeTask,
onUpdateTask,
onOpenDetail, onOpenDetail,
onOpenRefine, onOpenRefine,
onPauseTask, onPauseTask,
@@ -1943,7 +1960,7 @@ function TaskCardComponent({
task.prInfo, task.prInfo,
]); ]);
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => { const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
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 []; return [];
} }
const actions = [...taskActionMenuModel.actions]; const actions = [...taskActionMenuModel.actions];
@@ -1966,7 +1983,7 @@ function TaskCardComponent({
} }
} }
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect)); 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 hasContextMenuActions = contextMenuActions.length > 0;
const closeContextMenu = useCallback(() => { const closeContextMenu = useCallback(() => {
@@ -1975,7 +1992,7 @@ function TaskCardComponent({
useEffect(() => { useEffect(() => {
closeContextMenu(); closeContextMenu();
}, [closeContextMenu, task.column, task.id]); }, [closeContextMenu, task.column, task.githubTracking?.enabled, task.id]);
const clearLongPressTimer = useCallback(() => { const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) { if (longPressTimerRef.current) {

View File

@@ -77,6 +77,7 @@ export interface BuildTaskActionMenuModelOptions {
onMerge?: () => void; onMerge?: () => void;
onStartPrReview?: () => void; onStartPrReview?: () => void;
onCheckPrStatus?: () => void; onCheckPrStatus?: () => void;
onEnableGithubTracking?: () => void;
} }
export function getTaskPrAutomationLabel(t: TFunction<"app">, status?: string): string | undefined { 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 }); 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)) { if (hasResetHandler && isMutableLiveColumn(task.column, currentColumnFlags)) {
actions.push({ id: "reset", label: t("taskDetail.reset.btn", "Reset"), tone: "danger", onSelect: options.onReset }); 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" || task.status === "awaiting-approval" ||
canRetryTask || canRetryTask ||
isTaskPaused || isTaskPaused ||
hasAssignedAgent, hasAssignedAgent ||
Boolean(options.onEnableGithubTracking && task.githubTracking?.enabled !== true),
isTaskPaused, isTaskPaused,
}; };
} }

View File

@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
fetchBoardWorkflows: vi.fn(() => new Promise(() => {})), fetchBoardWorkflows: vi.fn(() => new Promise(() => {})),
rebuildTaskSpec: vi.fn().mockResolvedValue({}), rebuildTaskSpec: vi.fn().mockResolvedValue({}),
refreshPrStatus: vi.fn().mockResolvedValue({}), refreshPrStatus: vi.fn().mockResolvedValue({}),
updateTask: vi.fn(),
api: vi.fn().mockResolvedValue({ sessions: [] }), 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 mockConfirm = vi.fn();
const mockConfirmWithChoice = vi.fn(); const mockConfirmWithChoice = vi.fn();
@@ -717,6 +718,54 @@ describe("ListView", () => {
viewportSpy.mockRestore(); 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 () => { it("shows refine for custom workflow complete-column rows", async () => {
const viewportSpy = mockDesktopViewport(); const viewportSpy = mockDesktopViewport();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({ vi.mocked(fetchBoardWorkflows).mockResolvedValue({

View File

@@ -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(
<TaskCard
task={makeTask({ githubTracking: undefined })}
projectId="project-1"
onOpenDetail={onOpenDetail}
addToast={addToast}
onUpdateTask={onUpdateTask}
/>,
);
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(
<TaskCard
task={makeTask({ githubTracking: { enabled: true } as any })}
projectId="project-1"
onOpenDetail={onOpenDetail}
addToast={addToast}
onUpdateTask={onUpdateTask}
/>,
);
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 () => { it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry(); const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();

View File

@@ -48,6 +48,44 @@ describe("TaskContextMenu shared task action model", () => {
expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["delete", "respecify"]); 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", () => { it("exposes pause, unpause, and paused-by-agent note with detail labels", () => {
const active = buildTaskActionMenuModel({ task: makeTask(), t, columnLabel: columnLabel as any }); const active = buildTaskActionMenuModel({ task: makeTask(), t, columnLabel: columnLabel as any });
expect(active.actions.find((action) => action.id === "pause")?.label).toBe("Pause"); expect(active.actions.find((action) => action.id === "pause")?.label).toBe("Pause");

View File

@@ -703,7 +703,7 @@ export function useTasks(options?: UseTasksOptions) {
const updateTask = useCallback(async ( const updateTask = useCallback(async (
id: string, id: string,
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean } updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }
): Promise<Task> => { ): Promise<Task> => {
const previousTask = tasksRef.current.find((t) => t.id === id); const previousTask = tasksRef.current.find((t) => t.id === id);
const optimisticTask = previousTask const optimisticTask = previousTask