feat(workspace): Phase A U3 — dashboard "doesn't look broken" floor

Workspace tasks (no task.worktree, populated workspaceWorktrees) now render in
existing task views without crashing or going blank. New read-only
WorkspaceWorktreesSummary component (placeholder "N repos acquired" + a flat
repo→worktree/branch list — within the "doesn't look broken" ceiling, not a rich
status UI); TaskCard and TaskDetailModal nil-guard on isWorkspaceTask. Single-repo
rendering unchanged. CONCEPTS.md notes workspace-task merges are non-atomic
(repos land independently on local integration refs; partial-land is local and
operator-resettable). Tests 8/8; TaskCard regression 251/251.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 21:07:20 -07:00
parent 09bd01baf0
commit 023e4b057d
7 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": minor
---
Workspace tasks no longer render blank in the dashboard. Task cards and the task
detail view now surface a workspace task's acquired per-sub-repo worktrees as a
read-only "N repos acquired" placeholder and flat repo → worktree/branch list,
instead of an empty branch area (no `task.worktree`/`task.branch`).

View File

@@ -61,6 +61,10 @@ sub-directories. Fusion discovers sub-repos at init time and records them in
single root-level worktree; instead, the agent acquires per-repo worktrees
on demand via `fn_acquire_repo_worktree`.
Workspace-task merges are **non-atomic**: each sub-repo lands on its own local
integration ref independently, so a partial-land window (some sub-repos merged,
others not) is possible mid-task — this state is local and operator-resettable.
### Project Identity
The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement.

View File

@@ -35,6 +35,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ".
import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import { useRetryWarning } from "../context/RetryWarningContext";
import { useColumnLabel } from "../i18n/labels";
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
* on the task payload for the parallel-window badge (U9). */
@@ -625,6 +626,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.blockedBy === nextTask.blockedBy &&
previousTask.overlapBlockedBy === nextTask.overlapBlockedBy &&
previousTask.worktree === nextTask.worktree &&
// FNXC:Workspace 2026-06-21-00:00: re-render the card when a workspace task acquires/
// releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3).
Object.keys(previousTask.workspaceWorktrees ?? {}).length ===
Object.keys(nextTask.workspaceWorktrees ?? {}).length &&
previousTask.branch === nextTask.branch &&
previousTask.baseBranch === nextTask.baseBranch &&
previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks &&
@@ -2186,6 +2191,10 @@ function TaskCardComponent({
</div>
);
})()}
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.branch,
so the branch-metadata row below renders nothing. Surface the acquired sub-repos
as a compact "N repos acquired" placeholder so the card isn't blank (U3/KTD5). */}
{isWorkspaceTask(task) && <WorkspaceWorktreesSummary task={task} compact />}
{hasBranchMetadata && (
<div className="card-branch-row" aria-label={t("tasks.branchMetadata", "Branch metadata")}>
{branchMetadata.branch && (

View File

@@ -2209,3 +2209,39 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
color: var(--color-error);
font-size: 0.75rem;
}
/*
FNXC:Workspace 2026-06-21-00:00:
Flat read-only per-sub-repo worktree list for a workspace task (U3/KTD5 dashboard floor).
Read-only list/placeholder only — not the deferred rich per-repo-status component.
*/
.workspace-worktrees-summary {
margin: var(--space-sm) 0 0;
}
.workspace-worktrees-placeholder {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, inherit);
margin-bottom: var(--space-xs);
}
.workspace-worktrees-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.workspace-worktrees-item {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-sm);
font-size: 0.75rem;
font-family: var(--font-mono, monospace);
}
.workspace-worktrees-repo {
font-weight: 600;
}
.workspace-worktrees-branch {
color: var(--color-text-secondary, inherit);
}

View File

@@ -39,6 +39,7 @@ import { TaskChatTab } from "./TaskChatTab";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useNodes } from "../hooks/useNodes";
import { WorkflowResultsTab } from "./WorkflowResultsTab";
@@ -3065,6 +3066,10 @@ export function TaskDetailContent({
{task.branchContext?.groupId && (
<BranchGroupCard groupId={task.branchContext.groupId} projectId={projectId} />
)}
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular
task.worktree/task.branch; surface their acquired per-sub-repo worktrees
as a flat read-only list so the detail view isn't blank (U3/KTD5). */}
{isWorkspaceTask(task) && <WorkspaceWorktreesSummary task={task} />}
</>
)}
{task.status === "failed" && task.error && (

View File

@@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import type { Task } from "@fusion/core";
/*
FNXC:Workspace 2026-06-21-00:00:
Dashboard "doesn't look broken" floor (Phase A U3 / master U10, KTD5).
A workspace-mode task has NO singular `task.worktree`/`task.branch`; instead it carries
`task.workspaceWorktrees` — one acquired git worktree per sub-repo, keyed by repo path
relative to the workspace root. Existing display surfaces (TaskCard branch row, TaskDetail
metadata) key off the singular `task.branch`, so a workspace task would render an EMPTY
branch area — looking broken. This guard renders a static placeholder ("N repos acquired")
plus a flat read-only per-repo path/branch list so the task is observable, never crashing
and never blank.
Scope ceiling: flat read-only list / placeholder ONLY. A rich per-repo-status component
(live diff/lease/merge state per repo) is the deferred registration UI — out of scope here.
Single-repo rendering is untouched: callers only mount this when `isWorkspaceTask(task)`.
*/
/**
* True when the task is a workspace-mode task: no singular `worktree` recorded
* and at least one acquired per-sub-repo worktree in `workspaceWorktrees`.
* Single-repo tasks (populated `worktree`, no `workspaceWorktrees`) return false,
* keeping their existing rendering byte-for-byte unchanged.
*/
export function isWorkspaceTask(task: Pick<Task, "worktree" | "workspaceWorktrees">): boolean {
if (task.worktree) return false;
const entries = task.workspaceWorktrees;
return Boolean(entries && Object.keys(entries).length > 0);
}
interface WorkspaceWorktreesSummaryProps {
task: Pick<Task, "worktree" | "workspaceWorktrees">;
/** Compact variant for the dense TaskCard surface (placeholder only). */
compact?: boolean;
}
/**
* Read-only summary of a workspace task's acquired sub-repo worktrees.
*
* - `compact` (TaskCard): renders just the "N repos acquired" placeholder chip.
* - default (TaskDetail): renders the placeholder plus a flat per-repo list of
* `repo → worktreePath (branch)`.
*
* Renders nothing for non-workspace tasks; mount only behind `isWorkspaceTask`.
*/
export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWorktreesSummaryProps) {
const { t } = useTranslation("app");
const entries = task.workspaceWorktrees;
if (!isWorkspaceTask(task) || !entries) return null;
const repos = Object.entries(entries);
const placeholder = t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length });
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={placeholder}>
<span className="card-branch-label">{t("tasks.workspace", "Workspace")}</span>
<span className="card-branch-value">{placeholder}</span>
</span>
</div>
);
}
return (
<div
className="workspace-worktrees-summary"
data-testid="workspace-worktrees-summary"
aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}
>
<div className="workspace-worktrees-placeholder" data-testid="workspace-worktrees-placeholder">
{placeholder}
</div>
<ul className="workspace-worktrees-list">
{repos.map(([repoRelPath, info]) => (
<li key={repoRelPath} className="workspace-worktrees-item">
<span className="workspace-worktrees-repo" title={repoRelPath}>
{repoRelPath}
</span>
<span className="workspace-worktrees-path" title={info.worktreePath}>
{info.worktreePath}
</span>
<span className="workspace-worktrees-branch" title={info.branch}>
{info.branch}
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "../WorkspaceWorktreesSummary";
/*
FNXC:Workspace 2026-06-21-00:00:
U3/KTD5 dashboard "doesn't look broken" floor. Asserts the invariant across both surfaces
the summary serves (FN-5893):
- happy path: workspace task (no task.worktree, two workspaceWorktrees entries) renders a
flat per-repo list + "N repos acquired" placeholder — no crash, not blank.
- regression: single-repo task (task.worktree set, no workspaceWorktrees) renders nothing
from this guard, so its existing rendering stays unchanged.
Narrow seam: tests the presentational component directly, no API / SSE / timers (FN-5048).
*/
const workspaceTask = {
worktree: undefined,
workspaceWorktrees: {
"repo-a": { worktreePath: "/wt/repo-a", branch: "fusion/fn-1-a" },
"repo-b": { worktreePath: "/wt/repo-b", branch: "fusion/fn-1-b" },
},
} as const;
const singleRepoTask = {
worktree: "/wt/single",
workspaceWorktrees: undefined,
} as const;
describe("isWorkspaceTask", () => {
it("is true when worktree is absent and workspaceWorktrees has entries", () => {
expect(isWorkspaceTask(workspaceTask)).toBe(true);
});
it("is false for a single-repo task (worktree set)", () => {
expect(isWorkspaceTask(singleRepoTask)).toBe(false);
});
it("is false when workspaceWorktrees is an empty record", () => {
expect(isWorkspaceTask({ worktree: undefined, workspaceWorktrees: {} })).toBe(false);
});
it("prefers the singular worktree even if workspaceWorktrees is populated", () => {
expect(
isWorkspaceTask({ worktree: "/wt/x", workspaceWorktrees: workspaceTask.workspaceWorktrees }),
).toBe(false);
});
});
describe("WorkspaceWorktreesSummary", () => {
it("renders a flat per-repo list and placeholder for a two-repo workspace task (no crash, not empty)", () => {
render(<WorkspaceWorktreesSummary task={workspaceTask} />);
// Placeholder reflects the repo count.
expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2");
expect(screen.getByText(/2 repos acquired/i)).toBeTruthy();
// Flat per-repo list: each repo path, worktree path, and branch is shown.
const summary = screen.getByTestId("workspace-worktrees-summary");
expect(summary).toBeTruthy();
expect(screen.getByText("repo-a")).toBeTruthy();
expect(screen.getByText("repo-b")).toBeTruthy();
expect(screen.getByText("/wt/repo-a")).toBeTruthy();
expect(screen.getByText("/wt/repo-b")).toBeTruthy();
expect(screen.getByText("fusion/fn-1-a")).toBeTruthy();
expect(screen.getByText("fusion/fn-1-b")).toBeTruthy();
});
it("renders only the compact placeholder in compact mode", () => {
render(<WorkspaceWorktreesSummary task={workspaceTask} compact />);
expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos");
// Compact variant omits the full per-repo list.
expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull();
expect(screen.queryByText("/wt/repo-a")).toBeNull();
});
it("renders nothing for a single-repo task, leaving existing rendering unchanged", () => {
const { container } = render(<WorkspaceWorktreesSummary task={singleRepoTask} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull();
expect(screen.queryByTestId("workspace-worktrees-placeholder")).toBeNull();
});
it("renders nothing when workspaceWorktrees is empty", () => {
const { container } = render(
<WorkspaceWorktreesSummary task={{ worktree: undefined, workspaceWorktrees: {} }} />,
);
expect(container.firstChild).toBeNull();
});
});