diff --git a/.changeset/fn-9044-workspace-worktree-grouping.md b/.changeset/fn-9044-workspace-worktree-grouping.md new file mode 100644 index 0000000000..8885e164a5 --- /dev/null +++ b/.changeset/fn-9044-workspace-worktree-grouping.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 04a6103e2c..ce520922a4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -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 diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 42f8d5f8d0..4096b3bc62 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -1118,7 +1118,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree ) : ( worktreeGroups.map((group) => ( { const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf; if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined; @@ -127,9 +139,9 @@ function WorktreeGroupComponent({
- {label === upNextLabel || label === unassignedLabel ? : } + {kind === "unassigned" || kind === "up-next" ? : } - {label} + {headerLabel}
{activeTasks.map((task) => ( ({ }), })); 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[] }) => ( +
{label} {activeTasks.map((task) =>
{task.id}
)} {queuedTasks.map((task) =>
{task.id}
)} @@ -567,6 +567,34 @@ describe("Column worktree grouping setting", () => { 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( + , + ); + + 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", () => { const { rerender } = render( ({
), })); -vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () =>
})); +vi.mock("../WorktreeGroup", () => ({ + WorktreeGroup: ({ label, kind, activeTasks }: { label: string; kind: string; activeTasks: Task[] }) => ( +
+ {activeTasks.map((task) =>
{task.id}
)} +
+ ), +})); vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () =>
})); vi.mock("../PluginSlot", () => ({ PluginSlot: () => null })); vi.mock("lucide-react", () => ({ @@ -99,6 +105,22 @@ describe("Lane", () => { 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(); + + 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", () => { render(); diff --git a/packages/dashboard/app/components/__tests__/worktree-grouping-workspace.test.tsx b/packages/dashboard/app/components/__tests__/worktree-grouping-workspace.test.tsx new file mode 100644 index 0000000000..f29143e751 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/worktree-grouping-workspace.test.tsx @@ -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 }) =>
{task.id}
, +})); +vi.mock("lucide-react", () => ({ + ClipboardList: () => , + GitBranch: () => , +})); + +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( + )} + />, + ); +} + +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); + }); +}); diff --git a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts index 6461887833..96f3b1bcc3 100644 --- a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts +++ b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts @@ -110,6 +110,88 @@ describe("groupByWorktree", () => { 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", () => { const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" }); const paused = makeTask({ diff --git a/packages/dashboard/app/utils/worktreeGrouping.ts b/packages/dashboard/app/utils/worktreeGrouping.ts index 8cd0a82f33..23b7522370 100644 --- a/packages/dashboard/app/utils/worktreeGrouping.ts +++ b/packages/dashboard/app/utils/worktreeGrouping.ts @@ -1,9 +1,13 @@ -import type { Task } from "@fusion/core"; +import { isWorkspaceTask, type Task } from "@fusion/core"; import { getPathBasename } from "./pathDisplay"; import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles"; export interface WorktreeGroupData { + /** Stable identity; display labels collide for separate worktree paths. */ + id: string; + kind: "worktree" | "workspace" | "unassigned" | "up-next"; label: string; + repoCount?: number; activeTasks: Task[]; queuedTasks: Task[]; } @@ -85,9 +89,15 @@ export function groupByWorktree( */ dependencyColumnFlags?: ReadonlyMap[0]>, ): WorktreeGroupData[] { - // Separate assigned vs unassigned in-progress tasks - const assigned = inProgressTasks.filter((t) => t.worktree); - const unassigned = inProgressTasks.filter((t) => !t.worktree); + /* + FNXC:Workspace 2026-08-15-03:35: + 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 const worktreeMap = new Map(); @@ -143,15 +153,32 @@ export function groupByWorktree( for (const key of worktreeKeys) { groups.push({ + id: key, + kind: "worktree", label: getWorktreeLabel(key), activeTasks: worktreeMap.get(key)!, 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 if (unassigned.length > 0) { groups.push({ + id: "unassigned", + kind: "unassigned", label: "Unassigned", activeTasks: unassigned, queuedTasks: [], @@ -162,6 +189,8 @@ export function groupByWorktree( const queued = orderedEligible.slice(0, maxConcurrent); if (queued.length > 0) { groups.push({ + id: "up-next", + kind: "up-next", label: "Up Next", activeTasks: [], queuedTasks: queued, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 1dc43ecb22..284f3664ba 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -9301,7 +9301,9 @@ }, "worktree": { "unassigned": "Unassigned", - "upNext": "Up Next" + "upNext": "Up Next", + "workspaceRepos_one": "{{label}} · {{count}} repo", + "workspaceRepos_other": "{{label}} · {{count}} repos" }, "worktrunk": { "assetUrl": "Asset URL", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index bc81f833cd..af4ee872ec 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -9359,7 +9359,9 @@ export default interface Resources { }, "worktree": { "unassigned": "Unassigned", - "upNext": "Up Next" + "upNext": "Up Next", + "workspaceRepos_one": "{{label}} · {{count}} repo", + "workspaceRepos_other": "{{label}} · {{count}} repos" }, "worktrunk": { "assetUrl": "Asset URL",