diff --git a/.changeset/refresh-empty-task-activity-feed.md b/.changeset/refresh-empty-task-activity-feed.md new file mode 100644 index 0000000000..c7af4d0099 --- /dev/null +++ b/.changeset/refresh-empty-task-activity-feed.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refresh an empty task Activity Feed when operators open it after execution has started. +category: fix +dev: Shared task-detail hosts refetch complete task logs when an empty Feed is selected. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2ada0ed57d..5c05cd252c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -4355,14 +4355,46 @@ export function TaskDetailContent({ } }, [oversightActive, activitySegment]); + /* + FNXC:TaskActivityFeedFreshness 2026-08-07-08:30: + Task list and SSE snapshots intentionally strip task.log. If a shared detail host captured an + empty full-detail snapshot before activity was written, selecting Feed must retry that complete + read instead of preserving "(no activity)" forever. Populated feeds remain snapshot-stable and + incur no extra request; Live and Raw keep their independent streaming paths. + */ + const activityFeedIsEmpty = !workingTask.log?.length; + const refreshEmptyActivityFeed = useCallback(() => { + if (!activityFeedIsEmpty) return; + + const requestGeneration = ++detailRequestGenerationRef.current; + requestTaskDetail(task.id, projectId) + .then((detail) => { + if (!mountedRef.current + || detailRequestGenerationRef.current !== requestGeneration + || activeTaskIdRef.current !== detail.id) return; + + const promptResponse = latestPromptResponseRef.current; + const promptResponseMatchesDetail = promptResponse?.key === `${projectId ?? ""}:${detail.id}`; + const detailWithLatestPrompt = promptResponseMatchesDetail + ? { ...detail, prompt: promptResponse.prompt } as TaskDetail + : detail; + setFullDetail((previous) => previous?.id === detail.id + ? mergeTaskSnapshot(previous, detailWithLatestPrompt, { fullSnapshot: true }) + : detailWithLatestPrompt); + setDetailLoading(false); + }) + .catch(() => undefined); + }, [activityFeedIsEmpty, task.id, projectId, requestTaskDetail]); + const selectActivityView = useCallback((value: ActivitySegment) => { activityViewMenuViewportGuardUntilRef.current = 0; setActiveTab("chat"); setActivitySegment(value); + if (value === "feed") refreshEmptyActivityFeed(); setShowActivityViewMenu(false); setActivityViewMenuPosition(null); requestAnimationFrame(() => activityViewButtonRef.current?.focus()); - }, []); + }, [refreshEmptyActivityFeed]); const handleActivityTabKeyDown = useCallback((event: React.KeyboardEvent) => { const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown"); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index 3a210a3416..050aac3417 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -3,7 +3,7 @@ FNXC:TaskDetailTabs 2026-06-17-08:20: FN-7324 keeps the stable internal `chat` tab as Activity for explicit legacy links, but the omitted non-done default is now planner Chat. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Chat landing state. */ import { describe, it, expect, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import React, { type ComponentProps } from "react"; import userEvent from "@testing-library/user-event"; import { @@ -692,11 +692,14 @@ describe("TaskDetailModal GitHub tracking CTA", () => { }); describe("TaskDetailModal Activity feed loading", () => { - function renderActivityFeedModal(task: ReturnType | Record) { + function renderActivityFeedModal( + task: ReturnType | Record, + initialTab: ComponentProps["initialTab"] = "logs", + ) { return render( { expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); }); + /* + FNXC:TaskActivityFeedFreshness 2026-08-07-08:30: + Modal, main-panel, split-list, dock, popup, desktop, and mobile task details all render the shared + TaskDetailContent feed. Entering Feed must refresh its full task snapshot because board/SSE rows + deliberately carry log=[]; otherwise a detail opened before activity exists stays empty forever. + */ + it("refreshes an empty Feed when the persisted task log has gained activity", async () => { + const user = userEvent.setup(); + const { fetchTaskDetail } = await import("../../api"); + const refresh = createDeferred>(); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockReturnValueOnce(refresh.promise as any); + + renderActivityFeedModal( + makeTask({ id: "FN-FEED-REFRESH", prompt: "# Already loaded", log: [] }), + "chat", + ); + + await selectActivityView(user, "feed"); + expect(fetchTaskDetail).toHaveBeenCalledWith("FN-FEED-REFRESH", undefined); + + await act(async () => { + refresh.resolve(makeTask({ + id: "FN-FEED-REFRESH", + prompt: "# Already loaded", + log: [{ timestamp: "2026-08-07T08:20:00.000Z", action: "Executor started" }], + })); + }); + + expect(await screen.findByText("Executor started")).toBeInTheDocument(); + expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); + }); + it("preserves truncated activity message after detail load", async () => { const { fetchTaskDetail } = await import("../../api"); vi.mocked(fetchTaskDetail).mockReset();