Files
fusion/packages/dashboard/app/components/BranchGroupCard.tsx
gsxdsm e9de195ef5 FN-5822: wire shared branch-group visibility and merge controls
Add dashboard and API support for viewing shared branch groups and triggering group merge actions.

- add branch-group API routes and register them in integrated routers
- extend dashboard legacy API client with branch-group fetch and merge-control actions
- add BranchGroupCard UI + styles and surface it in task/detail/subtask views
- add dashboard/API test coverage for branch-group routes and UI integration points
- document branch-group behavior and add a changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5822-branch-group-dashboard.md       |   5 +
 docs/dashboard-guide.md                            |  31 ++++++
 packages/dashboard/app/api/legacy.ts               |  54 +++++++++
 .../dashboard/app/components/BranchGroupCard.css   |  83 ++++++++++++++
 .../dashboard/app/components/BranchGroupCard.tsx   | 123 +++++++++++++++++++++
 .../app/components/SubtaskBreakdownModal.tsx       |   3 +
 packages/dashboard/app/components/TaskCard.tsx     |  19 ++++
 .../dashboard/app/components/TaskDetailModal.tsx   |   4 +
 .../components/__tests__/BranchGroupCard.test.tsx  |  96 ++++++++++++++++
 .../__tests__/SubtaskBreakdownModal.test.tsx       |   1 +
 .../app/components/__tests__/TaskCard.test.tsx     |  16 ++++
 .../components/__tests__/TaskDetailModal.test.tsx  |  22 ++++
 .../src/__tests__/routes-branch-groups.test.ts     | 119 ++++++++++++++++++++
 .../src/routes/register-branch-groups-routes.ts    | 118 ++++++++++++++++++++
 .../src/routes/register-integrated-routers.ts      |  13 +++
 packages/dashboard/vitest.config.ts                |   4 +-
 16 files changed, 709 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-5822

Fusion-Task-Lineage: 199c25b8-f6ec-43b4-8fab-509f23fb5ac7
2026-06-01 04:47:19 -07:00

124 lines
4.5 KiB
TypeScript

import "./BranchGroupCard.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
import type { BranchGroupSummary } from "../api";
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
import { subscribeSse } from "../sse-bus";
interface BranchGroupCardProps {
groupId: string;
projectId?: string;
}
export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [promoting, setPromoting] = useState(false);
const loadGroup = useCallback(async () => {
try {
const response = await apiGetBranchGroup(groupId, projectId);
setGroup(response.group);
setError(null);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load branch group";
setError(message);
} finally {
setLoading(false);
}
}, [groupId, projectId]);
useEffect(() => {
setLoading(true);
void loadGroup();
}, [loadGroup]);
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
return subscribeSse(`/api/events${query}`, {
events: {
"task:updated": () => {
void loadGroup();
},
},
onReconnect: () => {
void loadGroup();
},
});
}, [loadGroup, projectId]);
const completionText = useMemo(() => {
if (!group) return "";
return `${group.completion.landed} of ${group.completion.total} members finished`;
}, [group]);
const onPromote = useCallback(async () => {
setPromoting(true);
try {
await apiPromoteBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setPromoting(false);
}
}, [groupId, loadGroup, projectId]);
if (loading) {
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> Loading branch group</div>;
}
if (error || !group) {
return <div className="card branch-group-card branch-group-card-error">{error ?? "Branch group unavailable"}</div>;
}
const completionPercent = group.completion.total > 0
? (group.completion.landed / group.completion.total) * 100
: 0;
const complete = group.completion.complete;
return (
<section className="card branch-group-card">
<header className="branch-group-card-header">
<div className="branch-group-card-title">
<GitBranch size={14} />
<strong>{group.branchName}</strong>
</div>
<span className="badge branch-group-card-badge">Group {group.id}</span>
</header>
<div className="branch-group-card-progress-text">{completionText}</div>
<div className="branch-group-card-progress" role="progressbar" aria-valuenow={group.completion.landed} aria-valuemin={0} aria-valuemax={group.completion.total}>
<span className="branch-group-card-progress-fill" style={{ width: `${completionPercent}%` }} />
</div>
<ul className="branch-group-card-members">
{group.members.map((member) => (
<li key={member.taskId} className="branch-group-card-member">
<span className={`status-dot ${member.landed ? "status-dot--online" : "status-dot--pending"}`} />
<span className="branch-group-card-member-title">{member.taskId} · {member.title}</span>
<span className="branch-group-card-member-status">{member.landed ? <CheckCircle2 size={14} /> : <CircleDashed size={14} />}</span>
</li>
))}
</ul>
{complete && (
<div className="branch-group-card-actions">
{group.prUrl && (
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
<GitPullRequest size={14} /> PR #{group.prNumber ?? "—"}
<ExternalLink size={12} />
</a>
)}
{group.autoMerge ? (
<span className="badge">Auto-merge enabled</span>
) : (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
{group.prState === "none" ? "Open PR" : "Merge group into main"}
</button>
)}
</div>
)}
</section>
);
}