From c1d7da3f8c7f51c515dbfbc4948b6d5e8aeab79f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 18:15:10 -0700 Subject: [PATCH] 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) --- .../refresh-branch-group-detail-live.md | 7 + .../app/components/BranchGroupCard.tsx | 33 +++-- .../app/components/GroupTaskModal.tsx | 40 ++--- .../__tests__/BranchGroupCard.test.tsx | 137 +++++++++++++++++- .../__tests__/GroupTaskModal.test.tsx | 58 +++++++- .../dashboard/app/utils/branchGroupSse.ts | 19 +++ 6 files changed, 262 insertions(+), 32 deletions(-) create mode 100644 .changeset/refresh-branch-group-detail-live.md create mode 100644 packages/dashboard/app/utils/branchGroupSse.ts diff --git a/.changeset/refresh-branch-group-detail-live.md b/.changeset/refresh-branch-group-detail-live.md new file mode 100644 index 0000000000..08e3879d46 --- /dev/null +++ b/.changeset/refresh-branch-group-detail-live.md @@ -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. diff --git a/packages/dashboard/app/components/BranchGroupCard.tsx b/packages/dashboard/app/components/BranchGroupCard.tsx index fd86065ce3..ced626c3be 100644 --- a/packages/dashboard/app/components/BranchGroupCard.tsx +++ b/packages/dashboard/app/components/BranchGroupCard.tsx @@ -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 ""; diff --git a/packages/dashboard/app/components/GroupTaskModal.tsx b/packages/dashboard/app/components/GroupTaskModal.tsx index fc152295d4..7392f66d94 100644 --- a/packages/dashboard/app/components/GroupTaskModal.tsx +++ b/packages/dashboard/app/components/GroupTaskModal.tsx @@ -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 ""; diff --git a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx index b6038d5430..7afb932b50 100644 --- a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx @@ -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 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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*\{(?[^}]*)\}/)?.groups?.block ?? ""; diff --git a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx index 56dd22478a..84404ac652 100644 --- a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx @@ -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 void>; + onReconnect?: () => void; + }; +}; + +const sseSubscriptions: SseSubscription[] = []; + vi.mock("../../api", async () => { const actual = await vi.importActual("../../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>) + .mockResolvedValueOnce({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }), + } as Awaited>); + + render(); + 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>) + .mockResolvedValueOnce({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }), + } as Awaited>); + + render(); + 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" }), diff --git a/packages/dashboard/app/utils/branchGroupSse.ts b/packages/dashboard/app/utils/branchGroupSse.ts new file mode 100644 index 0000000000..653e5221e0 --- /dev/null +++ b/packages/dashboard/app/utils/branchGroupSse.ts @@ -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; + } +}