FN-7321: refresh branch group details from task events

Refresh branch group detail views when task lifecycle events change member state.

- Add shared branch group SSE helpers for task lifecycle refresh events and project filtering.
- Subscribe branch group cards and modals to created, moved, updated, deleted, merged, and reconnect refreshes without resetting local view state.
- Cover live refresh behavior and add a patch changeset for the published CLI bundle.

Files changed:

 .changeset/refresh-branch-group-detail-live.md     |   7 ++
 .../dashboard/app/components/BranchGroupCard.tsx   |  33 +++--
 .../dashboard/app/components/GroupTaskModal.tsx    |  40 +++---
 .../components/__tests__/BranchGroupCard.test.tsx  | 137 ++++++++++++++++++++-
 .../components/__tests__/GroupTaskModal.test.tsx   |  58 ++++++++-
 packages/dashboard/app/utils/branchGroupSse.ts     |  19 +++
 6 files changed, 262 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-7321

Fusion-Task-Lineage: 015ba35b-c449-4e5c-a398-83a442ad360d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 18:15:10 -07:00
parent 5e0c567199
commit c1d7da3f8c
6 changed files with 262 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Refresh branch group task-detail completion live as member tasks change.
category: fix
dev: Refetches branch group summaries on task lifecycle SSE events and reconnect.

View File

@@ -1,10 +1,11 @@
import "./BranchGroupCard.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
import type { BranchGroupSummary } from "../api";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
import { subscribeSse } from "../sse-bus";
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
interface BranchGroupCardProps {
groupId: string;
@@ -37,25 +38,35 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
}
}, [groupId, projectId, t]);
const loadGroupRef = useRef(loadGroup);
useEffect(() => {
loadGroupRef.current = loadGroup;
}, [loadGroup]);
useEffect(() => {
setLoading(true);
void loadGroup();
}, [loadGroup]);
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const refreshFromCurrentGroup = (event?: MessageEvent) => {
if (event && !shouldRefreshBranchGroupForTaskEvent(event, projectId)) {
return;
}
void loadGroupRef.current();
};
/*
FNXC:BranchGroupDetails 2026-06-30-18:04:
Task-detail branch group refreshes use the current loader callback so live SSE updates do not reset the local collapsed/expanded state.
*/
const events = Object.fromEntries(BRANCH_GROUP_REFRESH_TASK_EVENTS.map((eventName) => [eventName, refreshFromCurrentGroup]));
return subscribeSse(`/api/events${query}`, {
events: {
"task:updated": () => {
void loadGroup();
},
},
onReconnect: () => {
void loadGroup();
},
events,
onReconnect: () => refreshFromCurrentGroup(),
});
}, [loadGroup, projectId]);
}, [projectId]);
const completionText = useMemo(() => {
if (!group) return "";

View File

@@ -1,9 +1,10 @@
import "./GroupTaskModal.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
import { subscribeSse } from "../sse-bus";
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
interface GroupTaskModalProps {
isOpen: boolean;
@@ -35,6 +36,12 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
}
}, [groupId, projectId]);
const loadGroupRef = useRef(loadGroup);
useEffect(() => {
loadGroupRef.current = loadGroup;
}, [loadGroup]);
useEffect(() => {
if (!isOpen || !groupId) return;
void loadGroup();
@@ -43,25 +50,22 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
useEffect(() => {
if (!isOpen || !groupId) return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const refreshFromCurrentGroup = (event?: MessageEvent) => {
if (event && !shouldRefreshBranchGroupForTaskEvent(event, projectId)) {
return;
}
void loadGroupRef.current();
};
/*
FNXC:BranchGroupDetails 2026-06-30-18:04:
The full branch group modal shares task-derived completion state with task details, so it listens to the same lifecycle SSE set while preserving its open/closed guard.
*/
const events = Object.fromEntries(BRANCH_GROUP_REFRESH_TASK_EVENTS.map((eventName) => [eventName, refreshFromCurrentGroup]));
return subscribeSse(`/api/events${query}`, {
events: {
"task:updated": (event) => {
try {
const payload = JSON.parse(event.data) as { projectId?: string };
if (projectId && payload.projectId && payload.projectId !== projectId) {
return;
}
} catch {
// no-op
}
void loadGroup();
},
},
onReconnect: () => {
void loadGroup();
},
events,
onReconnect: () => refreshFromCurrentGroup(),
});
}, [groupId, isOpen, loadGroup, projectId]);
}, [groupId, isOpen, projectId]);
const completionText = useMemo(() => {
if (!group) return "";

View File

@@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { BranchGroupCard } from "../BranchGroupCard";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
@@ -8,6 +8,16 @@ const apiGetBranchGroup = vi.fn();
const apiPromoteBranchGroup = vi.fn();
const apiAbandonBranchGroup = vi.fn();
type SseSubscription = {
url: string;
options: {
events?: Record<string, (event: MessageEvent) => void>;
onReconnect?: () => void;
};
};
const sseSubscriptions: SseSubscription[] = [];
vi.mock("../../api", () => ({
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
@@ -15,7 +25,10 @@ vi.mock("../../api", () => ({
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: () => () => {},
subscribeSse: (url: string, options: SseSubscription["options"]) => {
sseSubscriptions.push({ url, options });
return () => {};
},
}));
vi.mock("lucide-react", () => ({
@@ -61,6 +74,7 @@ describe("BranchGroupCard", () => {
apiGetBranchGroup.mockReset();
apiPromoteBranchGroup.mockReset();
apiAbandonBranchGroup.mockReset();
sseSubscriptions.length = 0;
});
it("hides promote control while incomplete", async () => {
@@ -193,6 +207,125 @@ describe("BranchGroupCard", () => {
expect(screen.getByRole("button", { name: /open pr/i })).toBeInTheDocument();
});
it("refetches on task:moved and updates completion, progress, member landed state, and completion-gated actions", async () => {
apiGetBranchGroup
.mockResolvedValueOnce({ group: makeGroup() })
.mockResolvedValueOnce({
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 },
],
}),
});
render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
await expandBranchGroup();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
expect(screen.queryByRole("button", { name: /open pr/i })).toBeNull();
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: JSON.stringify({ task: { id: "FN-2" } }) }));
});
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "2");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuemax", "2");
expect(screen.getByText("FN-2 · two").closest("li")?.querySelector(".status-dot")).toHaveClass("status-dot--online");
expect(screen.getByRole("button", { name: /open pr/i })).toBeInTheDocument();
});
it("refetches on membership lifecycle events and updates member rows without remounting", async () => {
apiGetBranchGroup
.mockResolvedValueOnce({ group: makeGroup() })
.mockResolvedValueOnce({
group: makeGroup({
completion: { landed: 1, total: 1, complete: true },
members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }],
}),
});
render(<BranchGroupCard groupId="BG-1" projectId="proj-a" />);
await screen.findByText("1 of 2 members finished");
await expandBranchGroup();
expect(screen.getByText("FN-2 · two")).toBeInTheDocument();
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:deleted"]?.(new MessageEvent("task:deleted", { data: JSON.stringify({ id: "FN-2", projectId: "proj-a" }) }));
});
expect(await screen.findByText("1 of 1 members finished")).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuemax", "1");
expect(screen.queryByText("FN-2 · two")).toBeNull();
});
it("ignores task lifecycle events for another project when payloads include a project id", async () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
render(<BranchGroupCard groupId="BG-1" projectId="proj-a" />);
await screen.findByText("1 of 2 members finished");
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:updated"]?.(new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-2", projectId: "proj-b" }) }));
});
expect(apiGetBranchGroup).toHaveBeenCalledTimes(1);
});
it("refetches on reconnect", async () => {
apiGetBranchGroup
.mockResolvedValueOnce({ group: makeGroup() })
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) });
render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
await act(async () => {
sseSubscriptions.at(-1)?.options.onReconnect?.();
});
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
});
it("preserves user expansion state across live refreshes", async () => {
apiGetBranchGroup
.mockResolvedValueOnce({ group: makeGroup() })
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) })
.mockResolvedValueOnce({ group: makeGroup() })
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) });
const { unmount } = render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
await expandBranchGroup();
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:updated"]?.(new MessageEvent("task:updated", { data: "{}" }));
});
expect(await screen.findByText("2 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();
unmount();
sseSubscriptions.length = 0;
render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: "{}" }));
});
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("FN-1 · one")).toBeNull();
});
it("keeps collapsed branch-group styling tokenized and compact", () => {
const css = loadAllAppCssBaseOnly();
const collapsedBlock = css.match(/\.branch-group-card--collapsed\s*\{(?<block>[^}]*)\}/)?.groups?.block ?? "";

View File

@@ -1,8 +1,18 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GroupTaskModal } from "../GroupTaskModal";
import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api";
type SseSubscription = {
url: string;
options: {
events?: Record<string, (event: MessageEvent) => void>;
onReconnect?: () => void;
};
};
const sseSubscriptions: SseSubscription[] = [];
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return {
@@ -18,6 +28,13 @@ vi.mock("../../hooks/useNavigationHistory", () => ({
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (url: string, options: SseSubscription["options"]) => {
sseSubscriptions.push({ url, options });
return () => {};
},
}));
const mockedGet = vi.mocked(apiGetBranchGroup);
const mockedPromote = vi.mocked(apiPromoteBranchGroup);
const mockedAbandon = vi.mocked(apiAbandonBranchGroup);
@@ -50,6 +67,7 @@ describe("GroupTaskModal", () => {
mockedPromote.mockReset();
mockedGet.mockReset();
mockedAbandon.mockReset();
sseSubscriptions.length = 0;
});
it("renders group summary and member open action", async () => {
@@ -147,6 +165,44 @@ describe("GroupTaskModal", () => {
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
it("refetches on task:moved and updates completion-gated actions in place", async () => {
mockedGet
.mockResolvedValueOnce({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>)
.mockResolvedValueOnce({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }),
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
await screen.findByText("1 of 2 members finished");
expect(screen.queryByRole("button", { name: /open pr/i })).toBeNull();
await act(async () => {
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: JSON.stringify({ task: { id: "FN-2" } }) }));
});
expect(await screen.findByText("2 of 2 members finished")).toBeDefined();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "2");
expect(screen.getByRole("button", { name: /open pr/i })).toBeDefined();
});
it("refetches the open group modal on reconnect", async () => {
mockedGet
.mockResolvedValueOnce({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>)
.mockResolvedValueOnce({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }),
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
await screen.findByText("1 of 2 members finished");
await act(async () => {
sseSubscriptions.at(-1)?.options.onReconnect?.();
});
expect(await screen.findByText("2 of 2 members finished")).toBeDefined();
});
it("shows terminal state and hides controls when merged", async () => {
mockedGet.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 3, prUrl: "https://github.com/org/repo/pull/3" }),

View File

@@ -0,0 +1,19 @@
/*
FNXC:BranchGroupDetails 2026-06-30-18:04:
Branch group summaries derive membership and landed completion from tasks, so dashboard subscribers refetch on each task lifecycle SSE event that can change those values.
*/
export const BRANCH_GROUP_REFRESH_TASK_EVENTS = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged"] as const;
export type BranchGroupRefreshTaskEvent = typeof BRANCH_GROUP_REFRESH_TASK_EVENTS[number];
export function shouldRefreshBranchGroupForTaskEvent(event: MessageEvent, projectId?: string): boolean {
if (!projectId) return true;
try {
const payload = JSON.parse(event.data) as { projectId?: string; task?: { projectId?: string } };
const payloadProjectId = payload.projectId ?? payload.task?.projectId;
return !payloadProjectId || payloadProjectId === projectId;
} catch {
return true;
}
}