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:
7
.changeset/fn-7161-worktree-grouping-setting.md
Normal file
7
.changeset/fn-7161-worktree-grouping-setting.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -315,6 +315,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
testCommand: undefined,
|
||||
buildCommand: undefined,
|
||||
recycleWorktrees: false,
|
||||
showWorktreeGrouping: false,
|
||||
executorAllowSiblingBranchRename: false,
|
||||
worktreeNaming: "random",
|
||||
worktrunk: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface MainContentProps {
|
||||
mainPanelDetailTask: Task | TaskDetail | null;
|
||||
filteredBoardTasks: Task[];
|
||||
maxConcurrent: number;
|
||||
showWorktreeGrouping: boolean;
|
||||
moveTask: (
|
||||
id: string,
|
||||
column: ColumnId,
|
||||
|
||||
@@ -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>
|
||||
{/*
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user