FN-080: prioritize acquired workspace repositories

Treat acquired workspace repositories as the authoritative task assignment and presentation source.\n\n- Route populated workspace entries ahead of stale singular worktree values.\n- Sort repository summaries and use singular-aware acquisition labels.\n- Expand regression coverage for full, compact, and grouped workspace views.\n\nFiles changed:\n .../app/components/WorkspaceWorktreesSummary.tsx   | 20 ++++++++++++-----\n .../app/components/__tests__/Column.test.tsx       |  5 +++--\n .../__tests__/WorkspaceWorktreesSummary.test.tsx   | 26 +++++++++++++++++++---\n .../app/utils/__tests__/worktreeGrouping.test.ts   | 24 +++++++++++---------\n packages/dashboard/app/utils/worktreeGrouping.ts   | 15 +++++++------\n 5 files changed, 62 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-080

Fusion-Task-Lineage: 4e864429-e4e5-418a-86db-c1ba3c25da30

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 20:19:59 +00:00
parent 89427dab11
commit 91ad40a30d
5 changed files with 62 additions and 28 deletions

View File

@@ -2,14 +2,17 @@ import { useTranslation } from "react-i18next";
import type { Task } from "@fusion/core";
/*
FNXC:Workspace 2026-08-15-07:05:
FNXC:Workspace 2026-08-20-20:05:
A populated workspaceWorktrees map fences stale singular routing in browser snapshots while the
store's asynchronous normalization catches up. Presentation must show every acquired repository,
even for a one-repository workspace, rather than hiding it behind task.worktree.
Task Detail lifts the former flat-list ceiling with durable landed/pending/failed repository
status. TaskCard remains count-only because its dense layout cannot safely grow a per-repo list;
legacy and empty rows stay pending because task error prose cannot attribute a repository failure.
*/
export function isWorkspaceTask(task: Pick<Task, "worktree" | "workspaceWorktrees">): boolean {
if (task.worktree) return false;
export function isWorkspaceTask(task: Pick<Task, "workspaceWorktrees">): boolean {
const entries = task.workspaceWorktrees;
return Boolean(entries && Object.keys(entries).length > 0);
}
@@ -38,17 +41,22 @@ export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWo
const entries = task.workspaceWorktrees;
if (!isWorkspaceTask(task) || !entries) return null;
const repos = Object.entries(entries);
const repos = Object.entries(entries).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
const statuses = repos.map(([repoRelPath, entry]) => ({ repoRelPath, entry, ...deriveWorkspaceRepoStatus(entry, repoRelPath, task.mergeDetails) }));
const landedCount = statuses.filter(({ status }) => status === "landed").length;
const hasStatusEvidence = statuses.some(({ status }) => status !== "pending");
const fullyLanded = landedCount === repos.length;
const acquiredLabel = t(
"tasks.workspaceReposAcquired",
repos.length === 1 ? "{{count}} repo acquired" : "{{count}} repos acquired",
{ count: repos.length },
);
const placeholder = hasStatusEvidence
? t("tasks.workspaceReposLanded", "{{landed}} of {{count}} repos landed", { landed: landedCount, count: repos.length })
: t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length });
: acquiredLabel;
if (compact) {
return <div className="card-branch-row" aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}><span className="card-branch-chip" data-testid="workspace-worktrees-placeholder" title={t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length })}><span className="card-branch-label">{t("tasks.workspace", "Workspace")}</span><span className="card-branch-value">{t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length })}</span></span></div>;
return <div className="card-branch-row" aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}><span className="card-branch-chip" data-testid="workspace-worktrees-placeholder" title={acquiredLabel}><span className="card-branch-label">{t("tasks.workspace", "Workspace")}</span><span className="card-branch-value">{acquiredLabel}</span></span></div>;
}
return <div className="workspace-worktrees-summary" data-testid="workspace-worktrees-summary" aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}>

View File

@@ -544,13 +544,13 @@ describe("Column worktree grouping setting", () => {
expect(screen.queryByTestId("task-FN-003")).toBeNull();
});
it("passes workspace tasks to a workspace group instead of Unassigned", () => {
it("passes a single acquired workspace repo to a workspace group instead of stale singular routing", () => {
const workspaceTask = {
...makeTask("FN-9044"),
column: "exec" as ColumnType,
worktree: "/ws/unrelated/.worktrees/stale-worktree",
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(
@@ -568,6 +568,7 @@ describe("Column worktree grouping setting", () => {
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-kind", "workspace");
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "FN-9044");
expect(screen.queryByText("stale-worktree")).toBeNull();
expect(screen.queryByText("Unassigned")).toBeNull();
expect(screen.getByTestId("group-active-FN-9044")).toBeInTheDocument();
});

View File

@@ -39,10 +39,10 @@ describe("isWorkspaceTask", () => {
expect(isWorkspaceTask({ worktree: undefined, workspaceWorktrees: {} })).toBe(false);
});
it("prefers the singular worktree even if workspaceWorktrees is populated", () => {
it("prefers populated acquired workspace entries over stale singular routing", () => {
expect(
isWorkspaceTask({ worktree: "/wt/x", workspaceWorktrees: workspaceTask.workspaceWorktrees }),
).toBe(false);
isWorkspaceTask({ worktree: "/wt/stale", workspaceWorktrees: workspaceTask.workspaceWorktrees }),
).toBe(true);
});
});
@@ -76,6 +76,26 @@ describe("WorkspaceWorktreesSummary", () => {
expect(screen.getByTestId("workspace-repo-base-fallback")).toHaveAttribute("title", expect.stringContaining("repo-b"));
});
it("renders the acquired workspace entry in full and compact modes despite stale singular routing", () => {
const staleWorkspaceTask = {
worktree: "/wt/unrelated-stale-worktree",
workspaceWorktrees: {
"repo-acquired": { worktreePath: "/wt/repo-acquired/.worktrees/FN-080", branch: "fusion/FN-080" },
},
} as const;
const { unmount } = render(<WorkspaceWorktreesSummary task={staleWorkspaceTask} />);
expect(screen.getByText(/1 repo acquired/i)).toBeTruthy();
expect(screen.getByText("repo-acquired")).toBeTruthy();
expect(screen.getByText("/wt/repo-acquired/.worktrees/FN-080")).toBeTruthy();
expect(screen.getByText("fusion/FN-080")).toBeTruthy();
expect(screen.queryByText("/wt/unrelated-stale-worktree")).toBeNull();
unmount();
render(<WorkspaceWorktreesSummary task={staleWorkspaceTask} compact />);
expect(screen.getByTestId("workspace-worktrees-placeholder")).toHaveTextContent("1 repo acquired");
});
it("renders only the compact placeholder in compact mode", () => {
render(<WorkspaceWorktreesSummary task={workspaceTask} compact />);
expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos");

View File

@@ -132,17 +132,20 @@ describe("groupByWorktree", () => {
expect(groups.find((group) => group.kind === "unassigned")).toBeUndefined();
});
it("uses a workspace group for a single acquired repo", () => {
it("uses a workspace group for a single acquired repo despite stale singular routing", () => {
const workspaceTask = makeTask({
id: "FN-9044",
worktree: "/ws/unrelated/.worktrees/stale-worktree",
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",
});
const groups = groupByWorktree([workspaceTask], [workspaceTask], 2);
expect(groups).toEqual([expect.objectContaining({
id: "workspace:FN-9044", kind: "workspace", repoCount: 1, label: "FN-9044",
})]);
expect(groups.some((group) => group.label === "stale-worktree" || group.kind === "unassigned")).toBe(false);
});
it("keeps tasks without acquired workspace worktrees unassigned", () => {
@@ -156,17 +159,18 @@ describe("groupByWorktree", () => {
})]);
});
it("prefers a singular worktree for transient rows that contain both shapes", () => {
const transient = makeTask({
it("derives a multi-repository workspace label from the sorted acquired entries, never stale routing", () => {
const workspaceTask = makeTask({
id: "FN-transient",
worktree: "/ws/.worktrees/single-worktree",
worktree: "/ws/.worktrees/unrelated-stale-worktree",
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-transient", branch: "fusion/FN-transient" },
"repo-z": { worktreePath: "/ws/repo-z/.worktrees/acquired-z", branch: "fusion/FN-transient" },
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/acquired-a", branch: "fusion/FN-transient" },
},
});
expect(groupByWorktree([transient], [transient], 2)).toEqual([expect.objectContaining({
id: "/ws/.worktrees/single-worktree", kind: "worktree", label: "single-worktree",
expect(groupByWorktree([workspaceTask], [workspaceTask], 2)).toEqual([expect.objectContaining({
id: "workspace:FN-transient", kind: "workspace", label: "acquired-a", repoCount: 2,
})]);
});

View File

@@ -90,14 +90,15 @@ export function groupByWorktree(
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.
FNXC:Workspace 2026-08-20-20:05:
A populated workspaceWorktrees map is authoritative over a stale singular worktree delivered
before asynchronous store normalization. Classify it as workspace first so a one-repository
workspace cannot be hidden under an unrelated singular group; stable ids still prevent
basename collisions between acquired repository paths.
*/
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));
const workspaceTasks = inProgressTasks.filter(isWorkspaceTask);
const assigned = inProgressTasks.filter((task) => !isWorkspaceTask(task) && Boolean(task.worktree));
const unassigned = inProgressTasks.filter((task) => !isWorkspaceTask(task) && !task.worktree);
// Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>();