Files
fusion/packages/dashboard/app/utils/worktreeGrouping.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

202 lines
7.6 KiB
TypeScript

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[];
}
/**
* Extract a clean display name from a worktree path.
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
return getPathBasename(worktreePath) || worktreePath;
}
/**
* Topological sort of tasks by dependency order.
* Mirrors resolveDependencyOrder from @fusion/core but inlined to avoid
* build alias issues (Vite aliases @fusion/core to types.ts only).
*/
function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const ordered: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(id: string): void {
if (visited.has(id)) return;
if (visiting.has(id)) return;
visiting.add(id);
const task = taskMap.get(id);
if (task) {
for (const depId of task.dependencies || []) {
if (taskMap.has(depId)) visit(depId);
}
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
}
for (const task of tasks) visit(task.id);
return ordered;
}
/**
* Group in-progress tasks by worktree and collect queued todo tasks
* as visual previews in the "Up Next" group.
*
* Queued tasks (eligible "todo" tasks whose dependencies are all satisfied)
* are always placed in the "Up Next" group — they are never distributed
* to worktree-specific groups since they have no worktree assignment yet.
* The number of queued tasks shown is capped at `maxConcurrent`.
*/
export function groupByWorktree(
inProgressTasks: Task[],
allTasks: Task[],
maxConcurrent: number,
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The ids of TASKS whose own column is a hold lane in their own workflow, when the caller
resolved them.
Task ids, not column ids (PR #2625 review — greptile). This helper scans `allTasks`, which
can span workflows, and column ids are namespaced per workflow — two workflows may both
declare `staging` with only one of them marking it `hold`. A unioned column-id set cannot
tell those apart and would list every executing card of the second workflow as waiting.
Only a card's own workflow can answer "is this waiting?", so the caller answers it per task
and passes the result.
Board resolves this; Lane does not pass it and keeps the legacy-id fallback.
*/
holdTaskIds?: ReadonlySet<string>,
/*
FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-dashboard-app):
Per-dependency column flags, mirroring the `holdTaskIds` seam directly above: the caller resolves,
this stays pure. Omitted -> the legacy ids, i.e. today's behaviour for every unconverted caller.
Without it, "are this card's dependencies satisfied?" was three legacy ids. On a renamed board a
FINISHED dependency matched none of them, so the dependent never appeared in the worktree view's
upcoming-work list — it looked like there was nothing ready to run while there was.
*/
dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>,
): WorktreeGroupData[] {
/*
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<string, Task[]>();
for (const task of assigned) {
const key = task.worktree!;
const list = worktreeMap.get(key) || [];
list.push(task);
worktreeMap.set(key, list);
}
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The filter wants "cards waiting for capacity" — the HOLD role, resolved from the board's
columns rather than the id `todo`.
THE BUG THIS CLOSES, measured rather than assumed: on the default board the id and the
role coincide (U11 gave `todo` the hold trait), so this looked healthy. On a board whose
hold column is renamed the filter matched NOTHING, so the worktree view showed no upcoming
work at all and read as idle — a whole panel silently empty, with nothing failing.
Dependency satisfaction below still names terminal ids. That is a separate question from
the hold role and is left alone deliberately: it needs `complete`/`mergeBlocker`/`archived`
traits for the DEPENDENCY's column, which is another lookup and another unit of work.
*/
// Find queued hold-lane tasks: cards in the hold column with all deps satisfied.
const taskById = new Map(allTasks.map((t) => [t.id, t]));
const isWaitingTask = (task: Task): boolean =>
holdTaskIds ? holdTaskIds.has(task.id) : isHoldColumnRole(undefined, task.column);
const todoTasks = allTasks.filter(isWaitingTask);
const eligible = todoTasks.filter((t) =>
!t.paused &&
(t.dependencies || []).every((depId) => {
const dep = taskById.get(depId);
if (!dep) return false;
const depFlags = dependencyColumnFlags?.get(dep.id);
/* Satisfied = the dependency rests in its OWN board's terminal pair, or its review lane —
the same union the scheduler's legacy rule uses, preserved exactly. */
return isCompleteColumnRole(depFlags, dep.column)
|| isArchivedColumnRole(depFlags, dep.column)
|| isReviewColumnRole(depFlags, dep.column);
}),
);
// Order eligible tasks by dependency order
const orderedIds = resolveDependencyOrder(eligible);
const orderedEligible = orderedIds
.map((id) => taskById.get(id))
.filter((t): t is Task => t !== undefined && eligible.includes(t));
// Build groups from worktree map
const groups: WorktreeGroupData[] = [];
const worktreeKeys = Array.from(worktreeMap.keys());
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: [],
});
}
// All eligible queued tasks go into the "Up Next" group (capped at maxConcurrent)
const queued = orderedEligible.slice(0, maxConcurrent);
if (queued.length > 0) {
groups.push({
id: "up-next",
kind: "up-next",
label: "Up Next",
activeTasks: [],
queuedTasks: queued,
});
}
return groups;
}