diff --git a/.changeset/fn-7161-worktree-grouping-setting.md b/.changeset/fn-7161-worktree-grouping-setting.md new file mode 100644 index 0000000000..7eec378c7f --- /dev/null +++ b/.changeset/fn-7161-worktree-grouping-setting.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a project setting to show or hide worktree names and grouping on the board. +category: feature +dev: New project setting `showWorktreeGrouping` (default false). When true, Column groups WIP tasks by worktree in both legacy and workflow modes; when false, WIP columns render plain task cards. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 959d28c0fd..8280bbb6f2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -292,6 +292,8 @@ For the stored settings shape, see [`customProviders` in the Settings Reference] Open **Settings → Worktrees** to maintain **Files to copy into new worktrees**. Add editable rows for repository-root-relative files such as `.env`, use **Browse** to select a project file, remove rows you no longer want, then Save. Fusion trims blank rows and de-duplicates paths before persisting. During task startup, configured regular files are copied into fresh or pooled task worktrees before the worktree init command and task execution begin; existing/resumed worktrees are not overwritten. Missing files, directories, absolute paths, traversal entries, and unreadable sources are skipped with non-fatal diagnostics and without logging file contents. See [`worktreeCopyFiles` in the Settings Reference](./settings-reference.md#project-settings) for the stored setting shape. +Use **Show worktree grouping on the board** in **Settings → Worktrees** when you want WIP/processing columns to always show worktree names and group cards by worktree. With the toggle off, Fusion preserves the legacy default: only the non-workflow `in-progress` column is grouped, and workflow-mode processing columns stay as plain cards. + ## Planning Mode Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e53533d200..0a441bdb51 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -454,6 +454,7 @@ Sandbox backend precedence is: | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | +| `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. | | `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. | | `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 96253e5ef3..82d42311b9 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -116,6 +116,18 @@ describe("settings defaults invariants", () => { }); }); + describe("showWorktreeGrouping default", () => { + it("keeps showWorktreeGrouping explicitly false in project defaults", () => { + expect(DEFAULT_PROJECT_SETTINGS.showWorktreeGrouping).toBe(false); + expect("showWorktreeGrouping" in DEFAULT_PROJECT_SETTINGS).toBe(true); + }); + + it("keeps showWorktreeGrouping project-scoped only", () => { + // showWorktreeGrouping intentionally has no DEFAULT_GLOBAL_SETTINGS counterpart. + expect("showWorktreeGrouping" in DEFAULT_GLOBAL_SETTINGS).toBe(false); + }); + }); + describe("mergeIntegrationWorktree default", () => { it("defaults project settings to reuse-task-worktree", () => { expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree"); diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 17b194aa7b..36b36306a6 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -40,6 +40,14 @@ describe("TaskStore", () => { }); }); + describe("showWorktreeGrouping setting", () => { + it("persists showWorktreeGrouping and returns it via getSettings", async () => { + await harness.store().updateSettings({ showWorktreeGrouping: true }); + const settings = await harness.store().getSettings(); + expect(settings.showWorktreeGrouping).toBe(true); + }); + }); + describe("worktreeCopyFiles setting", () => { it("round-trips populated copy-file paths via getSettings and project serialization", async () => { await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index e3e9f7d4ae..298e7b90e3 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -315,6 +315,7 @@ export const DEFAULT_PROJECT_SETTINGS = { testCommand: undefined, buildCommand: undefined, recycleWorktrees: false, + showWorktreeGrouping: false, executorAllowSiblingBranchRename: false, worktreeNaming: "random", worktrunk: { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index de329da5c3..efd9801693 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3666,6 +3666,13 @@ export interface ProjectSettings { * of being deleted. New tasks acquire a warm worktree from the pool, * preserving build caches (node_modules, target/, dist/). Default: false. */ recycleWorktrees?: boolean; + /** + * Controls whether the board shows worktree grouping and worktree-name labels in WIP/processing columns. + * + * FNXC:WorktreeGroupingSetting 2026-06-27-22:30: + * This is an explicit show/hide project setting. The default-off state hides worktree grouping and labels in both legacy and workflow-mode WIP columns; when enabled, operators see grouping in every WIP/processing column, including workflow-mode columns flagged as counting toward WIP. + */ + showWorktreeGrouping?: boolean; /** When true, restores the legacy behavior of silently creating sibling * branches like `fusion/FN-123-2` when the canonical task branch is already * checked out elsewhere. Default: false. */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index befe5099c0..b33776124d 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -527,6 +527,7 @@ function AppInner() { const { maxConcurrent, autoMerge, + showWorktreeGrouping, globalPaused, isTestMode, taskStuckTimeoutMs, @@ -1186,6 +1187,7 @@ function AppInner() { mainPanelDetailTask, filteredBoardTasks, maxConcurrent, + showWorktreeGrouping, moveTask, pauseTask, openTaskDetailInMainPanel, diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 2b33fcedd5..52aeebbba9 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -164,6 +164,38 @@ describe("WorktreesSection", () => { expect(onAdd).toHaveBeenCalledTimes(1); }); + it("renders and toggles the board worktree grouping checkbox", () => { + const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => { + if (typeof updater === "function") { + return updater({ recycleWorktrees: false, showWorktreeGrouping: false } as SettingsFormState); + } + return updater; + }); + + render( + , + ); + + const checkbox = screen.getByLabelText(/Show worktree grouping on the board/i) as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + + expect(setForm).toHaveBeenCalledTimes(1); + expect(setForm.mock.results[0]?.value).toMatchObject({ showWorktreeGrouping: true }); + }); + it("keeps an empty copy-file row reachable when the setting is undefined", () => { render( Promise; onPauseTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; @@ -136,7 +137,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { ); } -export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, 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, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, 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); const archivedLoadedRef = useRef(false); const boardRef = useRef(null); @@ -546,6 +547,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask allTasks={selectedWorkflowTasks} projectId={projectId} maxConcurrent={maxConcurrent} + showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPromote={handlePromote} canDropTask={(taskId) => canDropTask(taskId, columnDef.id, selectedWorkflow.id)} @@ -592,6 +594,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask allTasks={selectedWorkflowTasks} projectId={projectId} maxConcurrent={maxConcurrent} + showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPromote={handlePromote} canDropTask={(taskId) => canDropTask(taskId, selectedWorkflowArchivedColumn.id, selectedWorkflow.id)} @@ -639,6 +642,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask tasks={tasksByColumn[col]} projectId={projectId} maxConcurrent={maxConcurrent} + showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPauseTask={onPauseTask} onOpenDetail={onOpenDetail} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 4066bf1edc..01124e1a5a 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -91,6 +91,7 @@ interface ColumnProps { tasks: Task[]; projectId?: string; maxConcurrent: number; + showWorktreeGrouping: boolean; onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPauseTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; @@ -171,7 +172,7 @@ interface ColumnProps { getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, 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 @@ -240,21 +241,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived"; const isHoldColumn = workflowMode && Boolean(columnFlags?.hold); const isCollapsed = isArchived && collapsed; - // Legacy in-progress renders worktree groups (not paginated); in workflow - // mode there is no special-casing, so a processing column paginates normally. - const isLegacyInProgress = !workflowMode && column === "in-progress"; + const isWipProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress"; + /* + FNXC:WorktreeGroupingSetting 2026-06-27-22:30: + The project setting is an explicit show/hide control: worktree grouping and labels render only when enabled and only for the board's WIP/processing column. Turning it off must leave plain task cards with no legacy group shell in either legacy or workflow-mode columns. + */ + const showWorktreeGroups = showWorktreeGrouping === true && isWipProcessingColumn; // When search is active, skip pagination so all matching tasks are visible - const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD; + const shouldPaginate = !isArchived && !isSearchActive && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { - if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { + if (showWorktreeGroups || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); - }, [isLegacyInProgress, isArchived, tasks.length]); + }, [showWorktreeGroups, isArchived, tasks.length]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop @@ -369,12 +373,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, } }, [onPromote, t]); - // Worktree grouping is a legacy in-progress affordance; in workflow mode a - // custom processing column renders plain cards (KTD-11 keeps one-card-one-lane). const worktreeGroups = useMemo(() => { - if (!isLegacyInProgress) return []; - return groupByWorktree(tasks, tasks, maxConcurrent); - }, [isLegacyInProgress, tasks, maxConcurrent]); + if (!showWorktreeGroups) return []; + return groupByWorktree(tasks, allTasks ?? tasks, maxConcurrent); + }, [showWorktreeGroups, tasks, allTasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; @@ -706,7 +708,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, }} /> )} - {isLegacyInProgress ? ( + {showWorktreeGroups ? ( worktreeGroups.length === 0 ? (
{t("column.noTasks", "No tasks")}
) : ( diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index e9cf8ee9a9..0d73eef5d2 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -31,6 +31,7 @@ export interface LaneProps { onToggleCollapse: (workflowId: string) => void; projectId?: string; maxConcurrent: number; + showWorktreeGrouping?: boolean; onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPromote: (taskId: string) => Promise; /** Drag pre-check: null = allowed, else an i18n messageKey (R17). */ @@ -177,6 +178,7 @@ function LaneComponent(props: LaneProps) { allTasks={tasks} projectId={props.projectId} maxConcurrent={props.maxConcurrent} + showWorktreeGrouping={props.showWorktreeGrouping === true} onMoveTask={props.onMoveTask} onPromote={props.onPromote} canDropTask={makeCanDrop(col.id)} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 7423df5f7f..358a2831dd 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -732,6 +732,7 @@ export function SettingsModal({ mergeAdvanceAutoSync: "stash-and-ff", merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true }, recycleWorktrees: false, + showWorktreeGrouping: false, executorAllowSiblingBranchRename: false, worktreeNaming: "random", worktreeCopyFiles: [], diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 0e36c79471..3a153a7658 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -23,8 +23,12 @@ vi.mock("../TaskCard", () => ({ }), })); vi.mock("../WorktreeGroup", () => ({ - WorktreeGroup: () => ( -
+ WorktreeGroup: ({ label, activeTasks, queuedTasks }: { label: string; activeTasks: Task[]; queuedTasks: Task[] }) => ( +
+ {label} + {activeTasks.map((task) =>
{task.id}
)} + {queuedTasks.map((task) =>
{task.id}
)} +
), })); vi.mock("../QuickEntryBox", () => ({ @@ -94,6 +98,7 @@ beforeEach(() => { const defaultProps = { column: "triage" as ColumnType, maxConcurrent: 2, + showWorktreeGrouping: false, onMoveTask: vi.fn().mockResolvedValue({} as Task), onOpenDetail: vi.fn(), addToast: vi.fn(), @@ -242,6 +247,105 @@ describe("Column workflow mode (U9)", () => { }); }); +describe("Column worktree grouping setting", () => { + it("renders legacy in-progress columns as plain cards when the setting is off", () => { + const assigned = { ...makeTask("FN-001"), column: "in-progress" as ColumnType, worktree: "/repo/.worktrees/amber-finch" }; + render(); + + expect(screen.queryByTestId("worktree-group")).toBeNull(); + expect(screen.queryByText("amber-finch")).toBeNull(); + expect(screen.getByTestId("task-FN-001")).toBeInTheDocument(); + }); + + it("groups legacy in-progress tasks by worktree when the setting is on", () => { + const assigned = { ...makeTask("FN-001A"), column: "in-progress" as ColumnType, worktree: "/repo/.worktrees/amber-finch" }; + render(); + + expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "amber-finch"); + expect(screen.getByTestId("group-active-FN-001A")).toBeInTheDocument(); + expect(screen.queryByTestId("task-FN-001A")).toBeNull(); + }); + + it("renders workflow processing columns as plain cards when the setting is off", () => { + const assigned = { ...makeTask("FN-002"), column: "exec" as ColumnType, worktree: "/repo/.worktrees/workflow-wren" }; + render( + , + ); + + expect(screen.queryByTestId("worktree-group")).toBeNull(); + expect(screen.queryByText("workflow-wren")).toBeNull(); + expect(screen.getByTestId("task-FN-002")).toBeInTheDocument(); + }); + + it("groups workflow processing tasks by worktree when the setting is on", () => { + const assigned = { ...makeTask("FN-003"), column: "exec" as ColumnType, worktree: "/repo/.worktrees/workflow-hawk" }; + const unassigned = { ...makeTask("FN-004"), column: "exec" as ColumnType }; + const queued = { ...makeTask("FN-005"), column: "todo" as ColumnType }; + render( + , + ); + + expect(screen.getByText("workflow-hawk")).toBeInTheDocument(); + expect(screen.getByText("Unassigned")).toBeInTheDocument(); + expect(screen.getByText("Up Next")).toBeInTheDocument(); + expect(screen.getByTestId("group-active-FN-003")).toBeInTheDocument(); + expect(screen.getByTestId("group-active-FN-004")).toBeInTheDocument(); + expect(screen.getByTestId("group-queued-FN-005")).toBeInTheDocument(); + expect(screen.queryByTestId("task-FN-003")).toBeNull(); + }); + + it("does not leave worktree shells in empty processing columns", () => { + const { rerender } = render( + , + ); + + expect(screen.queryByTestId("worktree-group")).toBeNull(); + expect(screen.getByText("No tasks")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByTestId("worktree-group")).toBeNull(); + expect(screen.getByText("No tasks")).toBeInTheDocument(); + }); +}); + describe("Column memoization", () => { it("does not re-render task cards when rerendered with the same task references", () => { const tasks = [makeTask("FN-001")]; @@ -322,9 +426,9 @@ describe("Column pagination", () => { expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull(); }); - it("does not paginate in-progress columns", () => { + it("does not paginate grouped in-progress columns", () => { const tasks = Array.from({ length: 110 }, (_, index) => ({ ...makeTask(`KB-${String(index + 1).padStart(3, "0")}`), column: "in-progress" as ColumnType })); - render(); + render(); expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull(); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index a2293cb654..b43711f541 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -109,6 +109,7 @@ export function MainContent({ mainPanelDetailTask, filteredBoardTasks, maxConcurrent, + showWorktreeGrouping, moveTask, pauseTask, openTaskDetailInMainPanel, @@ -673,6 +674,7 @@ export function MainContent({ tasks={filteredBoardTasks} projectId={currentProject?.id} maxConcurrent={maxConcurrent} + showWorktreeGrouping={showWorktreeGrouping} onMoveTask={moveTask} onPauseTask={pauseTask} onOpenDetail={openTaskDetailInMainPanel} @@ -768,6 +770,7 @@ export function MainContent({ tasks={filteredBoardTasks} projectId={currentProject?.id} maxConcurrent={maxConcurrent} + showWorktreeGrouping={showWorktreeGrouping} onMoveTask={moveTask} onPauseTask={pauseTask} onOpenDetail={openTaskDetailInMainPanel} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 5cfef4200b..47ea89ec38 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -149,6 +149,7 @@ export interface MainContentProps { mainPanelDetailTask: Task | TaskDetail | null; filteredBoardTasks: Task[]; maxConcurrent: number; + showWorktreeGrouping: boolean; moveTask: ( id: string, column: ColumnId, diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx index a07d76a7e3..3a128c7a72 100644 --- a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx @@ -39,6 +39,11 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")} {t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup")}
+
+ + {t("settings.worktrees.showWorktreeGroupingHelp", "Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns.")} +
{/* diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 2111dc7a2c..47d181afb9 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -11,6 +11,7 @@ export interface UseAppSettingsResult { maxConcurrent: number; rootDir: string; autoMerge: boolean; + showWorktreeGrouping: boolean; testMode: boolean; isTestMode: boolean; globalPaused: boolean; @@ -48,6 +49,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [maxConcurrent, setMaxConcurrent] = useState(2); const [rootDir, setRootDir] = useState("."); const [autoMerge, setAutoMerge] = useState(true); + const [showWorktreeGrouping, setShowWorktreeGrouping] = useState(false); const [testMode, setTestMode] = useState(false); const [isTestMode, setIsTestMode] = useState(false); const [globalPaused, setGlobalPaused] = useState(false); @@ -88,6 +90,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { if (settingsResult.status === "fulfilled") { const settings = settingsResult.value; setAutoMerge(Boolean(settings.autoMerge)); + setShowWorktreeGrouping(settings.showWorktreeGrouping === true); const nextTestMode = settings.testMode === true; const nextIsTestMode = nextTestMode || settings.defaultProvider?.trim().toLowerCase() === "mock"; setTestMode(nextTestMode); @@ -226,6 +229,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { maxConcurrent, rootDir, autoMerge, + showWorktreeGrouping, testMode, isTestMode, globalPaused,