FN-7319: Collapse task-detail branch groups

Make shared-branch task details open in a compact collapsed state by default.

- Default branch group cards to collapsed and remount them when switching tasks in the same group.
- Add compact token-based styling for collapsed branch group summaries.
- Update branch group and task detail tests for collapsed defaults and state reset behavior.
- Add a patch changeset for the published CLI bundle.

Files changed:
 .changeset/fn-7319-compact-branch-groups.md        |  7 ++++
 .../dashboard/app/components/BranchGroupCard.css   | 38 +++++++++++++++++-
 .../dashboard/app/components/BranchGroupCard.tsx   | 30 +++-----------
 .../dashboard/app/components/TaskDetailModal.tsx   |  3 +-
 .../components/__tests__/BranchGroupCard.test.tsx  | 46 ++++++++++++++++++----
 .../components/__tests__/TaskDetailModal.test.tsx  | 38 +++++++++++++++---
 6 files changed, 123 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7319

Fusion-Task-Lineage: a4469ecd-cffa-4af4-b4dc-064246ff9a39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 17:15:59 -07:00
parent f677ab4f19
commit 2284d6683e
6 changed files with 123 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Collapse task-detail branch groups by default with a more compact summary.
category: fix
dev: Keeps branch-group member and PR actions available after expanding the task-detail card.

View File

@@ -4,6 +4,15 @@
gap: var(--space-sm);
}
/*
FNXC:BranchGroupDetails 2026-06-30-00:00:
Collapsed branch-group details must consume less task-detail space with token-based typography and spacing while keeping the expand button large enough to remain usable.
*/
.branch-group-card--collapsed {
gap: calc(var(--space-xs) / 2);
padding: var(--space-sm);
}
.branch-group-card-error {
color: var(--color-error);
}
@@ -21,12 +30,22 @@
gap: var(--space-xs);
}
.branch-group-card--collapsed .branch-group-card-title {
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
}
.branch-group-card-header-meta {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.branch-group-card--collapsed .branch-group-card-header,
.branch-group-card--collapsed .branch-group-card-header-meta {
gap: calc(var(--space-xs) / 2);
}
.branch-group-card-badge {
background: var(--surface-1);
}
@@ -39,13 +58,22 @@
padding: calc(var(--space-xs) / 2);
}
.branch-group-card--collapsed .branch-group-card-header-meta .btn-icon {
padding: 0;
}
.branch-group-card-header-meta .btn-icon svg {
display: block;
}
.branch-group-card-progress-text {
color: var(--text-muted);
font-size: 0.875rem;
font-size: calc(var(--font-size-base) * 0.875);
}
.branch-group-card--collapsed .branch-group-card-progress-text {
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
}
.branch-group-card-progress {
@@ -56,6 +84,10 @@
overflow: hidden;
}
.branch-group-card--collapsed .branch-group-card-progress {
height: calc(var(--space-xs) / 2);
}
.branch-group-card-progress-fill {
display: block;
height: 100%;
@@ -103,4 +135,8 @@
width: 100%;
justify-content: space-between;
}
.branch-group-card--collapsed .branch-group-card-header {
gap: calc(var(--space-xs) / 2);
}
}

View File

@@ -18,7 +18,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const [error, setError] = useState<string | null>(null);
const [promoting, setPromoting] = useState(false);
const [abandoning, setAbandoning] = useState(false);
const [collapsed, setCollapsed] = useState(false);
/*
FNXC:BranchGroupDetails 2026-06-30-00:00:
Task-detail branch groups must be collapsed by default on every breakpoint while preserving the user's expand/collapse control for member and action inspection.
*/
const [collapsed, setCollapsed] = useState(true);
const loadGroup = useCallback(async () => {
try {
@@ -38,28 +42,6 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
void loadGroup();
}, [loadGroup]);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 768px)");
const syncCollapsed = (matches: boolean) => {
setCollapsed(matches);
};
syncCollapsed(mediaQuery.matches);
const onMediaChange = (event: MediaQueryListEvent) => {
syncCollapsed(event.matches);
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", onMediaChange);
return () => mediaQuery.removeEventListener("change", onMediaChange);
}
mediaQuery.addListener(onMediaChange);
return () => mediaQuery.removeListener(onMediaChange);
}, []);
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
@@ -117,7 +99,7 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const complete = group.completion.complete;
return (
<section className="card branch-group-card">
<section className={`card branch-group-card${collapsed ? " branch-group-card--collapsed" : ""}`}>
<header className="branch-group-card-header">
<div className="branch-group-card-title">
<GitBranch size={14} />

View File

@@ -3115,7 +3115,8 @@ export function TaskDetailContent({
</div>
</div>
{task.branchContext?.groupId && (
<BranchGroupCard groupId={task.branchContext.groupId} projectId={projectId} />
/* FNXC:BranchGroupDetails 2026-06-30-00:00: Task-detail branch groups must return to their compact collapsed default when users switch tasks, including between members of the same shared branch group. Key by task and group so a manual expansion never leaks into the next task detail view. */
<BranchGroupCard key={`${task.id}:${task.branchContext.groupId}`} 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

View File

@@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { BranchGroupCard } from "../BranchGroupCard";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
const apiGetBranchGroup = vi.fn();
const apiPromoteBranchGroup = vi.fn();
@@ -28,6 +29,13 @@ vi.mock("lucide-react", () => ({
Loader2: () => null,
}));
async function expandBranchGroup() {
const toggle = await screen.findByRole("button", { name: /expand branch group/i });
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(screen.getByRole("button", { name: /collapse branch group/i })).toHaveAttribute("aria-expanded", "true");
}
function makeGroup(overrides: Record<string, unknown> = {}) {
return {
id: "BG-1",
@@ -59,6 +67,7 @@ describe("BranchGroupCard", () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
await expandBranchGroup();
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
@@ -69,6 +78,7 @@ describe("BranchGroupCard", () => {
apiPromoteBranchGroup.mockResolvedValue({ groupId: "BG-1", prState: "open" });
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
const button = await screen.findByRole("button", { name: /open pr/i });
fireEvent.click(button);
@@ -87,6 +97,7 @@ describe("BranchGroupCard", () => {
});
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
expect(await screen.findByText("Auto-merge enabled")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
@@ -96,6 +107,7 @@ describe("BranchGroupCard", () => {
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }], prState: "open", prNumber: 9, prUrl: "https://example/pr/9" }),
});
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument();
});
@@ -111,6 +123,7 @@ describe("BranchGroupCard", () => {
apiAbandonBranchGroup.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) });
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
const abandon = await screen.findByRole("button", { name: /abandon group/i });
fireEvent.click(abandon);
@@ -137,6 +150,7 @@ describe("BranchGroupCard", () => {
});
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
expect(await screen.findByRole("button", { name: /abandon group/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
@@ -146,6 +160,7 @@ describe("BranchGroupCard", () => {
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 5, prUrl: "https://example/pr/5" }),
});
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
expect(await screen.findByText("Group PR merged")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
});
@@ -155,23 +170,38 @@ describe("BranchGroupCard", () => {
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "closed", prNumber: 6, prUrl: "https://example/pr/6" }),
});
render(<BranchGroupCard groupId="BG-1" />);
await expandBranchGroup();
expect(await screen.findByText("Group PR closed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
});
it("shows members by default and collapses via toggle", async () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
it("starts populated groups collapsed and expands via toggle", async () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) });
render(<BranchGroupCard groupId="BG-1" />);
expect(await screen.findByText("FN-1 · one")).toBeInTheDocument();
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /collapse branch group/i });
expect(toggle).toHaveAttribute("aria-expanded", "true");
const toggle = screen.getByRole("button", { name: /expand branch group/i });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("FN-1 · one")).toBeNull();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
fireEvent.click(toggle);
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("FN-1 · one")).toBeNull();
expect(screen.getByText("1 of 2 members finished")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /collapse branch group/i })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("FN-1 · one")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open pr/i })).toBeInTheDocument();
});
it("keeps collapsed branch-group styling tokenized and compact", () => {
const css = loadAllAppCssBaseOnly();
const collapsedBlock = css.match(/\.branch-group-card--collapsed\s*\{(?<block>[^}]*)\}/)?.groups?.block ?? "";
expect(collapsedBlock).toContain("gap: calc(var(--space-xs) / 2)");
expect(collapsedBlock).toContain("padding: var(--space-sm)");
expect(collapsedBlock).not.toMatch(/\d+px|#[0-9a-f]{3,8}|rgba?\(/i);
const titleBlock = css.match(/\.branch-group-card--collapsed \.branch-group-card-title\s*\{(?<block>[^}]*)\}/)?.groups?.block ?? "";
expect(titleBlock).toContain("font-size: var(--font-size-xs)");
expect(titleBlock).toContain("line-height: var(--line-height-tight)");
});
});

View File

@@ -21,7 +21,16 @@ import {
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
vi.mock("../BranchGroupCard", () => ({
BranchGroupCard: ({ groupId }: { groupId: string }) => <div>Mock Branch Group {groupId}</div>,
BranchGroupCard: ({ groupId }: { groupId: string }) => {
const [expanded, setExpanded] = React.useState(false);
return (
<div>
Mock Branch Group {groupId}
<button type="button" onClick={() => setExpanded(true)}>Mock expand branch group</button>
{expanded && <span>Mock branch group expanded</span>}
</div>
);
},
}));
setupTaskDetailModalHooks();
@@ -593,20 +602,39 @@ describe("TaskDetailModal Raw Logs agent loading", () => {
});
describe("TaskDetailModal branch group surfacing", () => {
it("renders branch group card when task has group context", () => {
render(
const branchContext = { groupId: "BG-1", source: "planning", assignmentMode: "shared" } as const;
function renderTaskWithBranchContext(id: string) {
return (
<TaskDetailModal
initialTab="definition"
task={makeTask({ branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "shared" } })}
task={makeTask({ id, branchContext })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
/>
);
}
it("renders branch group card when task has group context", () => {
render(renderTaskWithBranchContext("FN-6041"));
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
});
it("remounts the branch group card when switching tasks inside the same group", async () => {
const user = userEvent.setup();
const { rerender } = render(renderTaskWithBranchContext("FN-6041"));
await user.click(screen.getByRole("button", { name: "Mock expand branch group" }));
expect(screen.getByText("Mock branch group expanded")).toBeInTheDocument();
rerender(renderTaskWithBranchContext("FN-6042"));
expect(screen.queryByText("Mock branch group expanded")).not.toBeInTheDocument();
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
});
});