fix(FN-8764): refresh stale task activity feed
This commit is contained in:
7
.changeset/refresh-empty-task-activity-feed.md
Normal file
7
.changeset/refresh-empty-task-activity-feed.md
Normal 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.
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user