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:
@@ -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")}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
Reference in New Issue
Block a user