diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx index c523510472..2034008f6f 100644 --- a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -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): boolean { - if (task.worktree) return false; +export function isWorkspaceTask(task: Pick): 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
{t("tasks.workspace", "Workspace")}{t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length })}
; + return
{t("tasks.workspace", "Workspace")}{acquiredLabel}
; } return
diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 8b79449afb..6fe8b89f52 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -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(); }); diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx index 6e29a81948..cc4627ae51 100644 --- a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -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(); + + 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(); + expect(screen.getByTestId("workspace-worktrees-placeholder")).toHaveTextContent("1 repo acquired"); + }); + it("renders only the compact placeholder in compact mode", () => { render(); expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos"); diff --git a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts index 96f3b1bcc3..850a681c49 100644 --- a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts +++ b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts @@ -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, })]); }); diff --git a/packages/dashboard/app/utils/worktreeGrouping.ts b/packages/dashboard/app/utils/worktreeGrouping.ts index 23b7522370..fdb314a57d 100644 --- a/packages/dashboard/app/utils/worktreeGrouping.ts +++ b/packages/dashboard/app/utils/worktreeGrouping.ts @@ -90,14 +90,15 @@ export function groupByWorktree( dependencyColumnFlags?: ReadonlyMap[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();