Files
fusion/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
gsxdsm 0188d9427b 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>
2026-08-14 20:50:55 -07:00

329 lines
14 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { groupByWorktree, getWorktreeLabel } from "../worktreeGrouping";
import type { Task } from "@fusion/core";
function makeTask(overrides: Partial<Task> & { id: string }): Task {
return {
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("getWorktreeLabel", () => {
it("extracts last path segment", () => {
expect(getWorktreeLabel(".worktrees/FN-001")).toBe("FN-001");
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
});
it("extracts humanized worktree names", () => {
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel("C:\\repo\\.worktrees\\quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
});
});
describe("groupByWorktree", () => {
it("groups active in-progress tasks by worktree", () => {
const t1 = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "FN-002", worktree: ".worktrees/quiet-robin" });
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe("swift-falcon");
expect(groups[0].activeTasks).toEqual([t1]);
expect(groups[1].label).toBe("quiet-robin");
expect(groups[1].activeTasks).toEqual([t2]);
});
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, queued], 2);
// Worktree group should have no queued tasks
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
expect(worktreeGroup).toBeDefined();
expect(worktreeGroup!.queuedTasks).toEqual([]);
// Up Next should contain the queued task
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
expect(upNext!.activeTasks).toEqual([]);
});
it("does not create Up Next group when there are no eligible queued tasks", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const groups = groupByWorktree([active], [active], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const blocked = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-003"], // KB-003 doesn't exist or isn't done
});
const groups = groupByWorktree([active], [active, blocked], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("respects maxConcurrent limit on queued tasks shown", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "FN-010", column: "todo" });
const q2 = makeTask({ id: "FN-011", column: "todo" });
const q3 = makeTask({ id: "FN-012", column: "todo" });
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toHaveLength(2);
});
it("places unassigned in-progress tasks in Unassigned group", () => {
const unassigned = makeTask({ id: "FN-001" }); // no worktree
const groups = groupByWorktree([unassigned], [unassigned], 2);
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe("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", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const paused = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
paused: true,
});
const normal = makeTask({
id: "FN-003",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, paused, normal], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["FN-003"]);
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("FN-002");
});
it("queued tasks with satisfied deps appear in Up Next", () => {
const done = makeTask({ id: "FN-001", column: "done" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-001"],
});
const groups = groupByWorktree([], [done, queued], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The upcoming-work list must find the HOLD lane by trait, not by the id `todo`.
WHY THIS ONE HID. On the default board the id and the role coincide — U11 gave `todo` the
hold trait — so every existing case here passed and the site looked healthy. Rename the
hold column and the filter matched nothing: the worktree view showed no upcoming work at
all and read as idle. A whole panel silently empty, nothing thrown.
Board resolves the hold ids across ALL workflows on the board (a card in another
workflow's hold lane is still upcoming work); Lane passes nothing and keeps the legacy
fallback, which is why the default case below omits the argument entirely.
REVERT CHECK, measured: dropping the parameter back to `t.column === "todo"` fails the
renamed case with an empty queue.
*/
describe("upcoming-work queue resolves the hold lane by trait", () => {
const mkTask = (id: string, column: string, extra: Record<string, unknown> = {}) =>
({
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
...extra,
} as never);
it("finds waiting cards in a RENAMED hold column when the ids are supplied", () => {
const waiting = mkTask("FN-50", "backlog");
const groups = groupByWorktree([], [waiting], 3, new Set(["FN-50"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-50");
});
it("finds nothing in a renamed hold column when the ids are NOT supplied", () => {
/*
Pins the fallback's real limit rather than pretending it covers custom boards: with no
resolved ids the legacy guess is all there is, and it cannot know about `backlog`. This
is the case that used to be the ONLY behaviour, on every board.
*/
const groups = groupByWorktree([], [mkTask("FN-51", "backlog")], 3);
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-51");
});
it("still finds legacy `todo` cards with no ids supplied, so Lane is unaffected", () => {
const groups = groupByWorktree([], [mkTask("FN-52", "todo")], 3);
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-52");
});
it("does not treat a task outside the resolved set as waiting", () => {
/*
The narrowing guard: a lookup that ignored its set would pass the first case. It is keyed
on TASK id, so this also pins the per-workflow scoping — a card whose own workflow does
not mark its column `hold` is absent from the set even if another workflow reuses the id
(PR #2625 review).
*/
const groups = groupByWorktree([], [mkTask("FN-53", "building")], 3, new Set(["FN-99"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-53");
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2625 review — greptile):
TWO WORKFLOWS, ONE COLUMN NAME, DIFFERENT TRAITS — the case that killed the first design.
My first version passed a board-wide set of hold COLUMN ids. Column ids are namespaced per
workflow, so workflow A can declare `staging` as its hold lane while workflow B declares
`staging` as executing. A unioned id set answers "is `staging` a hold column?" — a question
with no single answer — and every executing card in workflow B would have been listed under
Up Next as waiting work. Confidently wrong, which is worse than the renamed-board emptiness
the change set out to fix.
Keying on TASK id moves the decision to the only place that can make it: the caller, which
knows each task's own workflow. This case is the regression test for that, expressed the way
the helper now sees it — one card in the set, one not, both in a column called `staging`.
*/
describe("hold resolution is scoped per workflow, not per column name", () => {
const mkTask = (id: string, column: string) =>
({
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
} as never);
it("lists only the card whose OWN workflow marks `staging` as hold", () => {
const waitingInA = mkTask("FN-60", "staging");
const executingInB = mkTask("FN-61", "staging");
// What Board computes: FN-60's workflow declares `staging` hold; FN-61's does not.
const groups = groupByWorktree([], [waitingInA, executingInB], 3, new Set(["FN-60"]));
const queued = groups.flatMap((group) => group.queuedTasks ?? []).map((task: { id: string }) => task.id);
expect(queued).toContain("FN-60");
// The assertion the column-id design could not satisfy: same column name, opposite answer.
expect(queued).not.toContain("FN-61");
});
});