FN-9044: group workspace tasks by acquired worktrees
Show workspace-mode task cards in their own worktree group instead of Unassigned. - Classify workspace worktree groups with stable identifiers and repository counts. - Render localized workspace headers and appropriate group icons. - Cover workspace grouping across utilities, columns, lanes, and group headers. - Document the board behavior and add a patch changeset. Files changed: .changeset/fn-9044-workspace-worktree-grouping.md | 7 ++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Column.tsx | 4 +- .../dashboard/app/components/WorktreeGroup.tsx | 20 ++++-- .../app/components/__tests__/Column.test.tsx | 32 ++++++++- .../app/components/__tests__/Lane.test.tsx | 24 ++++++- .../__tests__/worktree-grouping-workspace.test.tsx | 66 +++++++++++++++++ .../app/utils/__tests__/worktreeGrouping.test.ts | 82 ++++++++++++++++++++++ packages/dashboard/app/utils/worktreeGrouping.ts | 37 ++++++++-- packages/i18n/locales/en/app.json | 4 +- packages/i18n/src/resources.d.ts | 4 +- 11 files changed, 267 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-9044 Fusion-Task-Lineage: d342d4fb-9fab-4efd-813f-2a3091d7c414 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9044-workspace-worktree-grouping.md
Normal file
7
.changeset/fn-9044-workspace-worktree-grouping.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Workspace-mode tasks now show their sub-repo worktrees on the board instead of Unassigned.
|
||||||
|
category: fix
|
||||||
|
dev: groupByWorktree groups expose stable id, kind, and workspace repoCount fields.
|
||||||
@@ -583,7 +583,7 @@ 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.
|
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.
|
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. Workspace-mode tasks appear in their own group labeled with the acquired worktree name and repository count; only tasks with no acquired worktree remain **Unassigned**. 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 Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1118,7 +1118,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
) : (
|
) : (
|
||||||
worktreeGroups.map((group) => (
|
worktreeGroups.map((group) => (
|
||||||
<WorktreeGroup
|
<WorktreeGroup
|
||||||
key={group.label}
|
key={group.id}
|
||||||
|
kind={group.kind}
|
||||||
|
repoCount={group.repoCount}
|
||||||
label={group.label}
|
label={group.label}
|
||||||
activeTasks={group.activeTasks}
|
activeTasks={group.activeTasks}
|
||||||
queuedTasks={group.queuedTasks}
|
queuedTasks={group.queuedTasks}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Task, TaskDetail, MergeResult, GithubIssueAction, ColumnId } from "@fusion/core";
|
import type { Task, TaskDetail, MergeResult, GithubIssueAction, ColumnId } from "@fusion/core";
|
||||||
|
import type { WorktreeGroupData } from "../utils/worktreeGrouping";
|
||||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/duplicates/near-duplicate-canonical";
|
import { isNearDuplicateCanonicalInactive } from "../../../core/src/duplicates/near-duplicate-canonical";
|
||||||
import { ClipboardList, GitBranch } from "lucide-react";
|
import { ClipboardList, GitBranch } from "lucide-react";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
@@ -10,7 +11,9 @@ import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
|||||||
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
||||||
|
|
||||||
interface WorktreeGroupProps {
|
interface WorktreeGroupProps {
|
||||||
|
kind: WorktreeGroupData["kind"];
|
||||||
label: string;
|
label: string;
|
||||||
|
repoCount?: number;
|
||||||
activeTasks: Task[];
|
activeTasks: Task[];
|
||||||
queuedTasks: Task[];
|
queuedTasks: Task[];
|
||||||
allTasks?: Task[];
|
allTasks?: Task[];
|
||||||
@@ -67,7 +70,9 @@ interface WorktreeGroupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WorktreeGroupComponent({
|
function WorktreeGroupComponent({
|
||||||
|
kind,
|
||||||
label,
|
label,
|
||||||
|
repoCount,
|
||||||
activeTasks,
|
activeTasks,
|
||||||
queuedTasks,
|
queuedTasks,
|
||||||
allTasks,
|
allTasks,
|
||||||
@@ -104,8 +109,15 @@ function WorktreeGroupComponent({
|
|||||||
taskContextMenuColumnsByTaskId,
|
taskContextMenuColumnsByTaskId,
|
||||||
}: WorktreeGroupProps) {
|
}: WorktreeGroupProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const upNextLabel = t("worktree.upNext", "Up Next");
|
/*
|
||||||
const unassignedLabel = t("worktree.unassigned", "Unassigned");
|
FNXC:Workspace 2026-08-15-03:35:
|
||||||
|
Group labels originate in a pure utility as English display values, while this component
|
||||||
|
translates headers. Comparing those strings selected the wrong icon in non-English locales;
|
||||||
|
the stable group kind is the locale-independent header contract.
|
||||||
|
*/
|
||||||
|
const headerLabel = kind === "workspace"
|
||||||
|
? t("worktree.workspaceRepos", "{{label}} · {{count}} repos", { label, count: repoCount ?? 0 })
|
||||||
|
: label;
|
||||||
const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => {
|
const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => {
|
||||||
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
|
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
|
||||||
if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined;
|
if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined;
|
||||||
@@ -127,9 +139,9 @@ function WorktreeGroupComponent({
|
|||||||
<div className="worktree-group">
|
<div className="worktree-group">
|
||||||
<div className="worktree-group-header">
|
<div className="worktree-group-header">
|
||||||
<span className="worktree-icon">
|
<span className="worktree-icon">
|
||||||
{label === upNextLabel || label === unassignedLabel ? <ClipboardList size={14} /> : <GitBranch size={14} />}
|
{kind === "unassigned" || kind === "up-next" ? <ClipboardList size={14} /> : <GitBranch size={14} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="worktree-label">{label}</span>
|
<span className="worktree-label">{headerLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeTasks.map((task) => (
|
{activeTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ vi.mock("../TaskCard", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../WorktreeGroup", () => ({
|
vi.mock("../WorktreeGroup", () => ({
|
||||||
WorktreeGroup: ({ label, activeTasks, queuedTasks }: { label: string; activeTasks: Task[]; queuedTasks: Task[] }) => (
|
WorktreeGroup: ({ label, kind, activeTasks, queuedTasks }: { label: string; kind: string; activeTasks: Task[]; queuedTasks: Task[] }) => (
|
||||||
<div data-testid="worktree-group" data-label={label} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}>
|
<div data-testid="worktree-group" data-label={label} data-kind={kind} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)}
|
{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>)}
|
{queuedTasks.map((task) => <div key={task.id} data-testid={`group-queued-${task.id}`}>{task.id}</div>)}
|
||||||
@@ -567,6 +567,34 @@ describe("Column worktree grouping setting", () => {
|
|||||||
expect(screen.queryByTestId("task-FN-003")).toBeNull();
|
expect(screen.queryByTestId("task-FN-003")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes workspace tasks to a workspace group instead of Unassigned", () => {
|
||||||
|
const workspaceTask = {
|
||||||
|
...makeTask("FN-9044"),
|
||||||
|
column: "exec" as ColumnType,
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column={"exec" as ColumnType}
|
||||||
|
workflowMode
|
||||||
|
columnDisplayName="Executing"
|
||||||
|
columnFlags={{ countsTowardWip: true }}
|
||||||
|
showWorktreeGrouping
|
||||||
|
tasks={[workspaceTask]}
|
||||||
|
allTasks={[workspaceTask]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-kind", "workspace");
|
||||||
|
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "FN-9044");
|
||||||
|
expect(screen.queryByText("Unassigned")).toBeNull();
|
||||||
|
expect(screen.getByTestId("group-active-FN-9044")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not leave worktree shells in empty processing columns", () => {
|
it("does not leave worktree shells in empty processing columns", () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<Column
|
<Column
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ vi.mock("../TaskCard", () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div data-testid="worktree-group" /> }));
|
vi.mock("../WorktreeGroup", () => ({
|
||||||
|
WorktreeGroup: ({ label, kind, activeTasks }: { label: string; kind: string; activeTasks: Task[] }) => (
|
||||||
|
<div data-testid="worktree-group" data-label={label} data-kind={kind}>
|
||||||
|
{activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> }));
|
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> }));
|
||||||
vi.mock("../PluginSlot", () => ({ PluginSlot: () => null }));
|
vi.mock("../PluginSlot", () => ({ PluginSlot: () => null }));
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
@@ -99,6 +105,22 @@ describe("Lane", () => {
|
|||||||
expect(headings).not.toContain("Archived");
|
expect(headings).not.toContain("Archived");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards workspace tasks through Column's worktree-grouping path", () => {
|
||||||
|
const workspaceTask = mkTask({
|
||||||
|
id: "FN-9044",
|
||||||
|
column: "in-progress",
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<Lane {...baseProps()} showWorktreeGrouping tasks={[workspaceTask]} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-kind", "workspace");
|
||||||
|
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "FN-9044");
|
||||||
|
expect(screen.getByTestId("group-active-FN-9044")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders creation controls only in the first visible column", () => {
|
it("renders creation controls only in the first visible column", () => {
|
||||||
render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />);
|
render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { WorktreeGroup } from "../WorktreeGroup";
|
||||||
|
|
||||||
|
vi.mock("../TaskCard", () => ({
|
||||||
|
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`}>{task.id}</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
ClipboardList: () => <svg data-testid="clipboard-icon" />,
|
||||||
|
GitBranch: () => <svg data-testid="git-branch-icon" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workspaceTask = {
|
||||||
|
id: "FN-9044",
|
||||||
|
title: "Workspace task",
|
||||||
|
description: "",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-08-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-03T00:00:00.000Z",
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
},
|
||||||
|
} as Task;
|
||||||
|
|
||||||
|
function renderGroup(kind: "workspace" | "unassigned", task: Task, repoCount?: number) {
|
||||||
|
return render(
|
||||||
|
<WorktreeGroup
|
||||||
|
{...({
|
||||||
|
kind,
|
||||||
|
label: kind === "workspace" ? "FN-9044" : "Unassigned",
|
||||||
|
repoCount,
|
||||||
|
activeTasks: [task],
|
||||||
|
queuedTasks: [],
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
addToast: vi.fn(),
|
||||||
|
} as React.ComponentProps<typeof WorktreeGroup>)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorktreeGroup workspace headers", () => {
|
||||||
|
it("renders the workspace group, not Unassigned, with a git branch icon and one task card", () => {
|
||||||
|
renderGroup("workspace", workspaceTask, 2);
|
||||||
|
|
||||||
|
expect(screen.getByText("FN-9044 · 2 repos")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Unassigned")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("git-branch-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("clipboard-icon")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByTestId("task-FN-9044")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a genuinely worktree-less task in the Unassigned group", () => {
|
||||||
|
renderGroup("unassigned", { ...workspaceTask, id: "FN-unassigned", workspaceWorktrees: undefined });
|
||||||
|
|
||||||
|
expect(screen.getByText("Unassigned")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("clipboard-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByTestId("task-FN-unassigned")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,6 +110,88 @@ describe("groupByWorktree", () => {
|
|||||||
expect(groups[0].activeTasks).toEqual([unassigned]);
|
expect(groups[0].activeTasks).toEqual([unassigned]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups workspace tasks by their acquired repo worktrees", () => {
|
||||||
|
const workspaceTask = makeTask({
|
||||||
|
id: "FN-9044",
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-c": { worktreePath: "/ws/repo-c/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = groupByWorktree([workspaceTask], [workspaceTask], 2);
|
||||||
|
|
||||||
|
expect(groups).toEqual([expect.objectContaining({
|
||||||
|
id: "workspace:FN-9044",
|
||||||
|
kind: "workspace",
|
||||||
|
label: "FN-9044",
|
||||||
|
repoCount: 3,
|
||||||
|
activeTasks: [workspaceTask],
|
||||||
|
})]);
|
||||||
|
expect(groups.find((group) => group.kind === "unassigned")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a workspace group for a single acquired repo", () => {
|
||||||
|
const workspaceTask = makeTask({
|
||||||
|
id: "FN-9044",
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groupByWorktree([workspaceTask], [workspaceTask], 2)[0]).toMatchObject({
|
||||||
|
kind: "workspace", repoCount: 1, label: "FN-9044",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tasks without acquired workspace worktrees unassigned", () => {
|
||||||
|
const emptyWorkspace = makeTask({ id: "FN-empty", workspaceWorktrees: {} });
|
||||||
|
const missingWorkspace = makeTask({ id: "FN-missing", workspaceWorktrees: undefined });
|
||||||
|
|
||||||
|
const groups = groupByWorktree([emptyWorkspace, missingWorkspace], [emptyWorkspace, missingWorkspace], 2);
|
||||||
|
|
||||||
|
expect(groups).toEqual([expect.objectContaining({
|
||||||
|
id: "unassigned", kind: "unassigned", activeTasks: [emptyWorkspace, missingWorkspace],
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a singular worktree for transient rows that contain both shapes", () => {
|
||||||
|
const transient = makeTask({
|
||||||
|
id: "FN-transient",
|
||||||
|
worktree: "/ws/.worktrees/single-worktree",
|
||||||
|
workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-transient", branch: "fusion/FN-transient" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groupByWorktree([transient], [transient], 2)).toEqual([expect.objectContaining({
|
||||||
|
id: "/ws/.worktrees/single-worktree", kind: "worktree", label: "single-worktree",
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps basename-colliding workspace and singular worktree groups distinct", () => {
|
||||||
|
const workspaceA = makeTask({ id: "FN-workspace-a", workspaceWorktrees: {
|
||||||
|
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/a" },
|
||||||
|
} });
|
||||||
|
const workspaceB = makeTask({ id: "FN-workspace-b", workspaceWorktrees: {
|
||||||
|
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/b" },
|
||||||
|
} });
|
||||||
|
const singleA = makeTask({ id: "FN-single-a", worktree: "/ws/repo-a/.worktrees/FN-9044" });
|
||||||
|
const singleB = makeTask({ id: "FN-single-b", worktree: "/ws/repo-b/.worktrees/FN-9044" });
|
||||||
|
|
||||||
|
const groups = groupByWorktree([workspaceA, workspaceB, singleA, singleB], [workspaceA, workspaceB, singleA, singleB], 2);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(4);
|
||||||
|
expect(groups.map((group) => group.id)).toEqual([
|
||||||
|
"/ws/repo-a/.worktrees/FN-9044",
|
||||||
|
"/ws/repo-b/.worktrees/FN-9044",
|
||||||
|
"workspace:FN-workspace-a",
|
||||||
|
"workspace:FN-workspace-b",
|
||||||
|
]);
|
||||||
|
expect(new Set(groups.map((group) => group.id)).size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
it("excludes paused todo tasks from Up Next", () => {
|
it("excludes paused todo tasks from Up Next", () => {
|
||||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const paused = makeTask({
|
const paused = makeTask({
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { Task } from "@fusion/core";
|
import { isWorkspaceTask, type Task } from "@fusion/core";
|
||||||
import { getPathBasename } from "./pathDisplay";
|
import { getPathBasename } from "./pathDisplay";
|
||||||
import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles";
|
import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles";
|
||||||
|
|
||||||
export interface WorktreeGroupData {
|
export interface WorktreeGroupData {
|
||||||
|
/** Stable identity; display labels collide for separate worktree paths. */
|
||||||
|
id: string;
|
||||||
|
kind: "worktree" | "workspace" | "unassigned" | "up-next";
|
||||||
label: string;
|
label: string;
|
||||||
|
repoCount?: number;
|
||||||
activeTasks: Task[];
|
activeTasks: Task[];
|
||||||
queuedTasks: Task[];
|
queuedTasks: Task[];
|
||||||
}
|
}
|
||||||
@@ -85,9 +89,15 @@ export function groupByWorktree(
|
|||||||
*/
|
*/
|
||||||
dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>,
|
dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>,
|
||||||
): WorktreeGroupData[] {
|
): WorktreeGroupData[] {
|
||||||
// Separate assigned vs unassigned in-progress tasks
|
/*
|
||||||
const assigned = inProgressTasks.filter((t) => t.worktree);
|
FNXC:Workspace 2026-08-15-03:35:
|
||||||
const unassigned = inProgressTasks.filter((t) => !t.worktree);
|
A workspace task legitimately has no singular `worktree` while owning one per-repository
|
||||||
|
worktree. Boolean(task.worktree) therefore is not its assignment test. Workspace worktrees
|
||||||
|
commonly share a basename, so groups use stable ids rather than labels as React keys.
|
||||||
|
*/
|
||||||
|
const assigned = inProgressTasks.filter((task) => Boolean(task.worktree));
|
||||||
|
const workspaceTasks = inProgressTasks.filter((task) => !task.worktree && isWorkspaceTask(task));
|
||||||
|
const unassigned = inProgressTasks.filter((task) => !task.worktree && !isWorkspaceTask(task));
|
||||||
|
|
||||||
// Group assigned tasks by worktree
|
// Group assigned tasks by worktree
|
||||||
const worktreeMap = new Map<string, Task[]>();
|
const worktreeMap = new Map<string, Task[]>();
|
||||||
@@ -143,15 +153,32 @@ export function groupByWorktree(
|
|||||||
|
|
||||||
for (const key of worktreeKeys) {
|
for (const key of worktreeKeys) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
id: key,
|
||||||
|
kind: "worktree",
|
||||||
label: getWorktreeLabel(key),
|
label: getWorktreeLabel(key),
|
||||||
activeTasks: worktreeMap.get(key)!,
|
activeTasks: worktreeMap.get(key)!,
|
||||||
queuedTasks: [],
|
queuedTasks: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const task of workspaceTasks) {
|
||||||
|
const entries = task.workspaceWorktrees!;
|
||||||
|
const firstRepo = Object.keys(entries).sort()[0]!;
|
||||||
|
groups.push({
|
||||||
|
id: `workspace:${task.id}`,
|
||||||
|
kind: "workspace",
|
||||||
|
label: getWorktreeLabel(entries[firstRepo]!.worktreePath),
|
||||||
|
repoCount: Object.keys(entries).length,
|
||||||
|
activeTasks: [task],
|
||||||
|
queuedTasks: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add unassigned group if needed
|
// Add unassigned group if needed
|
||||||
if (unassigned.length > 0) {
|
if (unassigned.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
id: "unassigned",
|
||||||
|
kind: "unassigned",
|
||||||
label: "Unassigned",
|
label: "Unassigned",
|
||||||
activeTasks: unassigned,
|
activeTasks: unassigned,
|
||||||
queuedTasks: [],
|
queuedTasks: [],
|
||||||
@@ -162,6 +189,8 @@ export function groupByWorktree(
|
|||||||
const queued = orderedEligible.slice(0, maxConcurrent);
|
const queued = orderedEligible.slice(0, maxConcurrent);
|
||||||
if (queued.length > 0) {
|
if (queued.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
id: "up-next",
|
||||||
|
kind: "up-next",
|
||||||
label: "Up Next",
|
label: "Up Next",
|
||||||
activeTasks: [],
|
activeTasks: [],
|
||||||
queuedTasks: queued,
|
queuedTasks: queued,
|
||||||
|
|||||||
@@ -9301,7 +9301,9 @@
|
|||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"unassigned": "Unassigned",
|
"unassigned": "Unassigned",
|
||||||
"upNext": "Up Next"
|
"upNext": "Up Next",
|
||||||
|
"workspaceRepos_one": "{{label}} · {{count}} repo",
|
||||||
|
"workspaceRepos_other": "{{label}} · {{count}} repos"
|
||||||
},
|
},
|
||||||
"worktrunk": {
|
"worktrunk": {
|
||||||
"assetUrl": "Asset URL",
|
"assetUrl": "Asset URL",
|
||||||
|
|||||||
4
packages/i18n/src/resources.d.ts
vendored
4
packages/i18n/src/resources.d.ts
vendored
@@ -9359,7 +9359,9 @@ export default interface Resources {
|
|||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"unassigned": "Unassigned",
|
"unassigned": "Unassigned",
|
||||||
"upNext": "Up Next"
|
"upNext": "Up Next",
|
||||||
|
"workspaceRepos_one": "{{label}} · {{count}} repo",
|
||||||
|
"workspaceRepos_other": "{{label}} · {{count}} repos"
|
||||||
},
|
},
|
||||||
"worktrunk": {
|
"worktrunk": {
|
||||||
"assetUrl": "Asset URL",
|
"assetUrl": "Asset URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user