FN-7161: add worktree grouping board setting

Add an opt-in project setting that controls whether board WIP columns show worktree grouping.

- Add the `showWorktreeGrouping` project setting with default-off schema, settings form wiring, and app state propagation.
- Apply the setting to board, lane, and column rendering so WIP/processing columns group by worktree only when enabled.
- Cover default persistence, settings UI, and column grouping behavior with targeted tests and document the operator-facing setting.

Files changed:
 .changeset/fn-7161-worktree-grouping-setting.md    |   7 ++
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-defaults.test.ts   |  12 +++
 packages/core/src/__tests__/store-settings.test.ts |   8 ++
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   7 ++
 packages/dashboard/app/App.tsx                     |   2 +
 .../app/__tests__/settings-sections.test.tsx       |  32 ++++++
 packages/dashboard/app/components/Board.tsx        |   6 +-
 packages/dashboard/app/components/Column.tsx       |  28 +++---
 packages/dashboard/app/components/Lane.tsx         |   2 +
 .../dashboard/app/components/SettingsModal.tsx     |   1 +
 .../app/components/__tests__/Column.test.tsx       | 112 ++++++++++++++++++++-
 .../app/components/dashboard/MainContent.tsx       |   3 +
 .../dashboard/app/components/dashboard/types.ts    |   1 +
 .../settings/sections/WorktreesSection.tsx         |   5 +
 packages/dashboard/app/hooks/useAppSettings.ts     |   4 +
 18 files changed, 216 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7161
Fusion-Task-Lineage: 4482522d-ea18-4b4f-b3fb-92af5ff5b9cd
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 22:37:19 -07:00
parent e5a0273282
commit 2448447de6
18 changed files with 216 additions and 18 deletions

View File

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

View File

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

View File

@@ -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/<task-id>-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. |

View File

@@ -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");

View File

@@ -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"] });

View File

@@ -315,6 +315,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
testCommand: undefined,
buildCommand: undefined,
recycleWorktrees: false,
showWorktreeGrouping: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktrunk: {

View File

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

View File

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

View File

@@ -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(
<WorktreesSection
scopeBanner={null}
form={{ recycleWorktrees: false, showWorktreeGrouping: false, worktreeCopyFiles: [] } as SettingsFormState}
setForm={setForm}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={vi.fn()}
onRemoveWorktreeCopyFile={vi.fn()}
onAddWorktreeCopyFile={vi.fn()}
onOpenWorktreeCopyFilePicker={vi.fn()}
/>,
);
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(
<WorktreesSection

View File

@@ -21,6 +21,7 @@ interface BoardProps {
tasks: Task[];
projectId?: string;
maxConcurrent: number;
showWorktreeGrouping: boolean;
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
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<HTMLElement | null>(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}

View File

@@ -91,6 +91,7 @@ interface ColumnProps {
tasks: Task[];
projectId?: string;
maxConcurrent: number;
showWorktreeGrouping: boolean;
onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
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 ? (
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (

View File

@@ -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<Task>;
onPromote: (taskId: string) => Promise<void>;
/** 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)}

View File

@@ -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: [],

View File

@@ -23,8 +23,12 @@ vi.mock("../TaskCard", () => ({
}),
}));
vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: () => (
<div data-testid="worktree-group" />
WorktreeGroup: ({ label, activeTasks, queuedTasks }: { label: string; activeTasks: Task[]; queuedTasks: Task[] }) => (
<div data-testid="worktree-group" data-label={label} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}>
<span>{label}</span>
{activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)}
{queuedTasks.map((task) => <div key={task.id} data-testid={`group-queued-${task.id}`}>{task.id}</div>)}
</div>
),
}));
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(<Column {...defaultProps} column="in-progress" tasks={[assigned]} allTasks={[assigned]} />);
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(<Column {...defaultProps} column="in-progress" showWorktreeGrouping tasks={[assigned]} allTasks={[assigned]} />);
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(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
tasks={[assigned]}
allTasks={[assigned]}
/>,
);
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(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
showWorktreeGrouping
tasks={[assigned, unassigned]}
allTasks={[assigned, unassigned, queued]}
/>,
);
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(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
showWorktreeGrouping
tasks={[]}
allTasks={[]}
/>,
);
expect(screen.queryByTestId("worktree-group")).toBeNull();
expect(screen.getByText("No tasks")).toBeInTheDocument();
rerender(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
showWorktreeGrouping={false}
tasks={[]}
allTasks={[]}
/>,
);
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(<Column {...defaultProps} column="in-progress" tasks={tasks} />);
render(<Column {...defaultProps} column="in-progress" showWorktreeGrouping tasks={tasks} />);
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
});

View File

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

View File

@@ -149,6 +149,7 @@ export interface MainContentProps {
mainPanelDetailTask: Task | TaskDetail | null;
filteredBoardTasks: Task[];
maxConcurrent: number;
showWorktreeGrouping: boolean;
moveTask: (
id: string,
column: ColumnId,

View File

@@ -39,6 +39,11 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
<input id="recycleWorktrees" type="checkbox" checked={form.recycleWorktrees} onChange={(e) => setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")}</label>
<small>{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")}</small>
</div>
<div className="form-group">
<label htmlFor="showWorktreeGrouping" className="checkbox-label">
<input id="showWorktreeGrouping" type="checkbox" checked={form.showWorktreeGrouping === true} onChange={(e) => setForm((f) => ({ ...f, showWorktreeGrouping: e.target.checked }))}/>{t("settings.worktrees.showWorktreeGrouping", " Show worktree grouping on the board ")}</label>
<small>{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.")}</small>
</div>
<div className="form-group">
<label>{t("settings.worktrees.filesToCopyIntoNewWorktrees", "Files to copy into new worktrees")}</label>
{/*

View File

@@ -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<string>(".");
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,