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:
8
.changeset/workspace-dashboard-floor.md
Normal file
8
.changeset/workspace-dashboard-floor.md
Normal 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`).
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user