fix(FN-8764): refresh stale task activity feed

This commit is contained in:
gsxdsm
2026-08-07 15:01:21 -07:00
parent 1f9b0e644a
commit 55e8484418
3 changed files with 79 additions and 4 deletions

View File

@@ -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.

View File

@@ -4355,14 +4355,46 @@ export function TaskDetailContent({
} }
}, [oversightActive, activitySegment]); }, [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) => { const selectActivityView = useCallback((value: ActivitySegment) => {
activityViewMenuViewportGuardUntilRef.current = 0; activityViewMenuViewportGuardUntilRef.current = 0;
setActiveTab("chat"); setActiveTab("chat");
setActivitySegment(value); setActivitySegment(value);
if (value === "feed") refreshEmptyActivityFeed();
setShowActivityViewMenu(false); setShowActivityViewMenu(false);
setActivityViewMenuPosition(null); setActivityViewMenuPosition(null);
requestAnimationFrame(() => activityViewButtonRef.current?.focus()); requestAnimationFrame(() => activityViewButtonRef.current?.focus());
}, []); }, [refreshEmptyActivityFeed]);
const handleActivityTabKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => { const handleActivityTabKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown"); const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown");

View File

@@ -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. 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 { 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 React, { type ComponentProps } from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { import {
@@ -692,11 +692,14 @@ describe("TaskDetailModal GitHub tracking CTA", () => {
}); });
describe("TaskDetailModal Activity feed loading", () => { describe("TaskDetailModal Activity feed loading", () => {
function renderActivityFeedModal(task: ReturnType<typeof makeTask> | Record<string, unknown>) { function renderActivityFeedModal(
task: ReturnType<typeof makeTask> | Record<string, unknown>,
initialTab: ComponentProps<typeof TaskDetailModal>["initialTab"] = "logs",
) {
return render( return render(
<TaskDetailModal <TaskDetailModal
task={task as any} task={task as any}
initialTab="logs" initialTab={initialTab}
onClose={noop} onClose={noop}
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
@@ -783,6 +786,39 @@ describe("TaskDetailModal Activity feed loading", () => {
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); 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<ReturnType<typeof makeTask>>();
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 () => { it("preserves truncated activity message after detail load", async () => {
const { fetchTaskDetail } = await import("../../api"); const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset(); vi.mocked(fetchTaskDetail).mockReset();