diff --git a/.changeset/fn-205-feed-freshness.md b/.changeset/fn-205-feed-freshness.md new file mode 100644 index 0000000000..f4588a6307 --- /dev/null +++ b/.changeset/fn-205-feed-freshness.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep task Activity Feed entries current and accept focus resume diagnostics. +category: fix +dev: Retains journals in mergeTaskSnapshot, resyncs Feed through SSE, and shares resume triggers. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 76e5bf103d..eb224bdff0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1726,6 +1726,8 @@ The **Activity** tab is the first task-detail tab by default and presents **Live The top-level **Chat** tab opens a task-aware Chat conversation for the same task instead of posting steering comments. It targets the project Direct Chat default model and thinking level through one Brain popover with model-only targeting; it retains the synthetic `task-planner:` session so server-built task context and scoped tools remain intact. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no task-Chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the task Chat session and sends that prompt as an ordinary chat message through the task-context-aware task-Chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Task Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. +The **Feed** is the task's activity journal. It refreshes when it becomes visible, whenever the task changes while you are viewing it, and after its event stream reconnects. It does not poll while hidden; opening or returning to Feed performs the authoritative refresh, so it remains current without background traffic. + The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed: - Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 06a347a6b4..ca9e22bdd6 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -201,11 +201,11 @@ Direct-report stale decisions in `HeartbeatMonitor.buildReportsHealthSection()` Dashboard Phase 1 resume instrumentation adds observation-only client/server traces for refetch/reconnect attribution. It does not change visibility/pageshow/SSE behavior; FN-5392 consumes this data for fixes. - Client event shape (`ResumeEvent`): `{ ts, view, trigger, projectId?, gapMs?, replayAttempted, replayFromEventId?, lastEventId?, sseChannel?, reason?, detail? }`. -- Trigger taxonomy: `visibility`, `pageshow`, `sse-error`, `sse-reconnect`, `sse-open`, `remount`, `route-active`, `route-inactive`, `project-context-change`. +- Trigger taxonomy: `visibility`, `focus`, `pageshow`, `sse-error`, `sse-reconnect`, `sse-open`, `remount`, `route-active`, `route-inactive`, `project-context-change`. The browser and diagnostics route both import this accepted vocabulary from `packages/dashboard/src/shared/resume-triggers.ts`; `focus` is accepted for tab-return diagnostics. - Sources: - `sse-bus` (`pageshow`, visible `visibilitychange`, `openChannel`, `forceReconnect`, EventSource `error`) - - Hooks: `useTasks` (`visibility`, `sse-reconnect`), `useChatRooms` (`sse-reconnect`), `useChat` (`sse-open`, `project-context-change`) - - Components: `Board` and `ChatView` mount/unmount route markers (`remount` / `route-active` / `route-inactive`) + - Hooks: `useTasks` (`visibility`, `focus`, `sse-reconnect`), `useChatRooms` (`sse-reconnect`), `useChat` (`sse-open`, `project-context-change`) + - Components: `Board` and `ChatView` mount/unmount route markers (`remount` / `route-active` / `route-inactive`), and `taskActivityFeed` when Feed becomes visible or its stream reconnects - Access paths: - Client ring (500): `window.__fusionDebug.resumeInstrumentation.get()` / `.clear()` - Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }` diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 4865ed9721..415628363f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -92,6 +92,7 @@ import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/f import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; import { copyTextToClipboard } from "../utils/copyToClipboard"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; +import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval"; import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel"; import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveTaskChat, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; @@ -111,6 +112,7 @@ const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120; const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320; const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350; const PROMPT_REFRESH_INTERVAL_MS = 5_000; +const TASK_FEED_RESYNC_DEBOUNCE_MS = 750; function isPromptRefreshLifecycleActive(task: Pick): boolean { if (task.status === "planning" || task.status === "needs-replan") return true; @@ -1747,6 +1749,11 @@ export function TaskDetailContent({ const activityViewMenuRef = useRef(null); const activityViewButtonRef = useRef(null); const activityViewMenuViewportGuardUntilRef = useRef(0); + const activityFeedResyncTimerRef = useRef(null); + const activityFeedResyncRequestRef = useRef<{ + key: string; + needsFollowUp: boolean; + } | null>(null); // Plugin UI slots for task-detail-tab const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId); @@ -4469,18 +4476,29 @@ 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; + FNXC:TaskActivityFeedFreshness 2026-08-28-00:13: + A Feed resync shares an initial slim-task detail request without invalidating its loading settlement. + Later reads are deduplicated by requestTaskDetail and still fence against the current task generation. - const requestGeneration = ++detailRequestGenerationRef.current; - requestTaskDetail(task.id, projectId) + FNXC:TaskActivityFeedFreshness 2026-08-28-00:42: + FN-205 must not lose an activity append that arrives while the shared initial detail request is in + flight. Mark that request dirty and issue one follow-up authoritative read after it settles; asking + requestTaskDetail during the flight would only receive the stale shared promise again. + */ + const resyncActivityFeed = useCallback((reason: string) => { + void reason; + const requestKey = `${projectId ?? ""}:${task.id}`; + const activeRequest = activityFeedResyncRequestRef.current; + if (activeRequest?.key === requestKey) { + activeRequest.needsFollowUp = true; + return; + } + + const requestGeneration = detailRequestGenerationRef.current; + const promise = requestTaskDetail(task.id, projectId); + const request = { key: requestKey, needsFollowUp: false }; + activityFeedResyncRequestRef.current = request; + void promise .then((detail) => { if (!mountedRef.current || detailRequestGenerationRef.current !== requestGeneration @@ -4494,39 +4512,87 @@ export function TaskDetailContent({ setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detailWithLatestPrompt, { fullSnapshot: true }) : detailWithLatestPrompt); - setDetailLoading(false); }) - .catch(() => undefined); - }, [activityFeedIsEmpty, task.id, projectId, requestTaskDetail]); + .catch(() => undefined) + .finally(() => { + if (activityFeedResyncRequestRef.current !== request) return; + activityFeedResyncRequestRef.current = null; + if (!request.needsFollowUp + || !mountedRef.current + || activeTaskIdRef.current !== task.id) return; + resyncActivityFeed("task-updated-during-refresh"); + }); + }, [task.id, projectId, requestTaskDetail]); /* - FNXC:TaskActivityFeedFreshness 2026-08-26-12:20: - Rescue an empty Feed whenever it is VISIBLE, not only when the operator switches to it. - - The retry used to hang off `selectActivityView`, so it could not fire on a card that OPENS on Feed - — which is how a deep link and the board's activity affordance land (`initialTab: "logs"`). Combined - with the mount effect trusting `"prompt" in task` as proof of a complete detail (an SSE snapshot - keeps `prompt` and empties `log`), a done task with real entries displayed "(no activity)" - permanently. Reported from a live board. - - The guard inside `refreshEmptyActivityFeed` still returns immediately when the feed is populated, so - a card that already holds its journal pays nothing — and a genuinely empty task asks once, because - the callback identity is stable while it stays empty. + FNXC:TaskActivityFeedFreshness 2026-08-28-00:13: + FN-205 requires Feed to read authoritative activity on becoming visible, after each update for its + task while visible, and after an SSE reconnect. The journal is stripped from stream payloads, so this + coalesced detail read is the only live source for appended entries. Do not poll while the Feed is + hidden or its kept-alive host is inactive (FNXC:TaskPopupViewGating); reveal immediately resyncs it. */ useEffect(() => { if (!active || activeTab !== "chat" || activitySegment !== "feed") return; - refreshEmptyActivityFeed(); - }, [active, activeTab, activitySegment, refreshEmptyActivityFeed]); + + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const scheduleResync = () => { + if (activityFeedResyncTimerRef.current !== null) { + window.clearTimeout(activityFeedResyncTimerRef.current); + } + activityFeedResyncTimerRef.current = window.setTimeout(() => { + activityFeedResyncTimerRef.current = null; + resyncActivityFeed("task-updated"); + }, TASK_FEED_RESYNC_DEBOUNCE_MS); + }; + const handleTaskUpdated = (event: MessageEvent) => { + try { + const updatedTask = JSON.parse(event.data) as { id?: unknown }; + if (updatedTask.id === task.id) scheduleResync(); + } catch { + // FNXC:TaskActivityFeedFreshness 2026-08-28-00:13: Ignore malformed stream payloads; the next authoritative resync remains available. + } + }; + const handleReconnect = () => { + recordResumeEvent({ + view: "taskActivityFeed", + trigger: "sse-reconnect", + projectId, + replayAttempted: false, + reason: "sse-reconnect", + }); + resyncActivityFeed("sse-reconnect"); + }; + + recordResumeEvent({ + view: "taskActivityFeed", + trigger: "route-active", + projectId, + replayAttempted: false, + reason: "segment-visible", + }); + resyncActivityFeed("segment-visible"); + const unsubscribe = subscribeSse(`/api/events${query}`, { + onReconnect: handleReconnect, + events: { "task:updated": handleTaskUpdated }, + }); + + return () => { + if (activityFeedResyncTimerRef.current !== null) { + window.clearTimeout(activityFeedResyncTimerRef.current); + activityFeedResyncTimerRef.current = null; + } + unsubscribe(); + }; + }, [active, activeTab, activitySegment, projectId, resyncActivityFeed, task.id]); 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.feed-stripped-snapshot.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.feed-stripped-snapshot.test.tsx index 81abe22c07..3b8e102675 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.feed-stripped-snapshot.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.feed-stripped-snapshot.test.tsx @@ -1,41 +1,28 @@ /* -FNXC:TaskActivityFeedFreshness 2026-08-26-12:20: -REPORTED: a task that is done, and demonstrably has journal entries, shows "(no activity)" in Feed. - -Two things combine, and either alone is harmless: - -1. `stripTaskListHeavyFields` (sse.ts) empties `log` and KEEPS every other field, `prompt` included. - So an SSE `task:updated` payload for a task that has a spec arrives with `prompt` present and - `log: []`. -2. The detail mount effect treats `"prompt" in task` as proof the prop is a complete TaskDetail and - returns WITHOUT requesting the detail. `prompt` and `log` are stripped by different paths, so that - proxy is false exactly for the payload above. - -The card therefore adopts a log-less snapshot as if it were complete, and the only rescue — -`refreshEmptyActivityFeed` — is bound to a segment CHANGE. When Feed is already the active segment -(opening on `initialTab: "logs"`, which is how a deep link and the board's activity affordance land), -nothing ever changes segment, so "(no activity)" is permanent for that visit. - -These tests pin the observable outcome, not the internals: entries the server has must reach the Feed -however the card was opened. +FNXC:TaskActivityFeedFreshness 2026-08-28-00:13: +FN-205 fixes two observable Feed failures: a prompt-bearing, log-stripped SSE snapshot cannot erase +entries already read from task detail, and a visible Feed refreshes its authoritative journal when it +opens, receives its task update, or reconnects. Every host renders TaskDetailContent, so overlay, +embedded, desktop, and mobile assertions exercise the shared contract rather than a host-specific fix. */ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; import React, { type ComponentProps } from "react"; import { makeTask, noop, noopDelete, noopMerge, - noopMove, noopOpenDetail, setupTaskDetailModalHooks, + taskDetailSseSubscriptions, } from "./TaskDetailModal.test-helpers"; -import { TaskDetailModal } from "../TaskDetailModal"; +import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal"; setupTaskDetailModalHooks(); const SERVER_ENTRY = "Step 2 (Testing & Verification) → done"; +const SECOND_ENTRY = "Task activity updated while Feed stayed open"; function renderModal(props: Partial> = {}) { return render( @@ -50,16 +37,50 @@ function renderModal(props: Partial> = {} ); } +function renderEmbedded(task = strippedSnapshot()) { + return render( + , + ); +} + /** Exactly what an SSE snapshot looks like: spec present, journal emptied. */ -function strippedSnapshot() { +function strippedSnapshot(overrides: Record = {}) { return makeTask({ id: "FN-FEED-1", column: "done" as never, prompt: "# Task FN-FEED-1\n\n## Steps\n\n### Step 1: Do the thing\n", log: [], + ...overrides, }); } +function activityFeedSubscription() { + const subscription = [...taskDetailSseSubscriptions].reverse().find( + (candidate) => candidate.options.events?.["task:updated"], + ); + if (!subscription) throw new Error("Expected an Activity Feed SSE subscription"); + return subscription; +} + +function emitTaskUpdated(id: string): void { + const handler = activityFeedSubscription().options.events?.["task:updated"]; + if (!handler) throw new Error("Expected an Activity Feed task:updated handler"); + act(() => handler({ data: JSON.stringify({ id }) } as MessageEvent)); +} + +afterEach(() => { + vi.useRealTimers(); +}); + describe("Feed on a log-stripped snapshot", () => { it("fetches the real detail when the card opens straight onto an empty Feed", async () => { const { fetchTaskDetail } = await import("../../api"); @@ -72,17 +93,12 @@ describe("Feed on a log-stripped snapshot", () => { renderModal({ task: strippedSnapshot(), initialTab: "logs" }); - // The rescue must not depend on the operator switching segments: Feed is already open. expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); - expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalled(); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); }); - /* - A task that genuinely has no entries must still say so, and must not spin: the rescue is guarded on - emptiness, so one attempt per visit is the contract, not a retry loop. - */ - it("still reports an honestly empty journal, and asks only once", async () => { + it("still reports an honestly empty journal and asks once on Feed entry", async () => { const { fetchTaskDetail } = await import("../../api"); vi.mocked(fetchTaskDetail).mockReset(); vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask({ id: "FN-FEED-2", log: [] }) as never); @@ -90,27 +106,238 @@ describe("Feed on a log-stripped snapshot", () => { renderModal({ task: makeTask({ id: "FN-FEED-2", prompt: "# spec", log: [] }), initialTab: "logs" }); expect(await screen.findByText("(no activity)")).toBeInTheDocument(); - expect(vi.mocked(fetchTaskDetail).mock.calls.length).toBeLessThanOrEqual(1); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); }); - /* - The documented property that must not regress: a card opened with real entries already in hand - renders them without paying for a request. - */ - it("does not re-request when the prop already carries the journal", async () => { + it("refreshes a populated Feed once without hiding entries while detail is pending", async () => { const { fetchTaskDetail } = await import("../../api"); vi.mocked(fetchTaskDetail).mockReset(); + let resolveDetail!: (detail: ReturnType) => void; + const pendingDetail = new Promise>((resolve) => { + resolveDetail = resolve; + }); + vi.mocked(fetchTaskDetail).mockImplementation(() => pendingDetail as never); renderModal({ task: makeTask({ - id: "FN-FEED-3", - prompt: "# spec", - log: [{ timestamp: "2026-08-26T08:00:00.000Z", action: "Already present" }], + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], }), initialTab: "logs", }); - expect(screen.getByText("Already present")).toBeInTheDocument(); - expect(vi.mocked(fetchTaskDetail)).not.toHaveBeenCalled(); + await waitFor(() => expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1)); + expect(screen.getByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); + + await act(async () => { + resolveDetail(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + })); + await pendingDetail; + }); + }); + + it("keeps loaded entries through a newer prompt-bearing stripped parent snapshot", async () => { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + }) as never); + + const { rerender } = renderModal({ task: strippedSnapshot(), initialTab: "logs" }); + expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); + }); + + it("coalesces own-task updates into one authoritative Feed refresh", async () => { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail) + .mockResolvedValueOnce(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + }) as never) + .mockResolvedValueOnce(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [ + { timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }, + { timestamp: "2026-08-26T08:46:00.000Z", action: SECOND_ENTRY }, + ], + }) as never); + + renderModal({ task: strippedSnapshot(), initialTab: "logs" }); + expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); + + emitTaskUpdated("FN-OTHER"); + await act(async () => { + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(751); + }); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); + + emitTaskUpdated("FN-FEED-1"); + emitTaskUpdated("FN-FEED-1"); + emitTaskUpdated("FN-FEED-1"); + await act(async () => { + await vi.advanceTimersByTimeAsync(751); + }); + + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(2); + expect(screen.getByText(SECOND_ENTRY)).toBeInTheDocument(); + }); + + it("follows an update received while the initial Feed refresh is still pending", async () => { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + let resolveInitialDetail!: (detail: ReturnType) => void; + const initialDetail = new Promise>((resolve) => { + resolveInitialDetail = resolve; + }); + vi.mocked(fetchTaskDetail) + .mockImplementationOnce(() => initialDetail as never) + .mockResolvedValueOnce(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [ + { timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }, + { timestamp: "2026-08-26T08:46:00.000Z", action: SECOND_ENTRY }, + ], + }) as never); + + renderModal({ task: strippedSnapshot(), initialTab: "logs" }); + await waitFor(() => expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1)); + + emitTaskUpdated("FN-FEED-1"); + await act(async () => { + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(751); + }); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + + await act(async () => { + resolveInitialDetail(makeTask({ + id: "FN-FEED-1", + prompt: "# Complete detail", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + })); + await initialDetail; + }); + + await waitFor(() => expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(2)); + expect(await screen.findByText(SECOND_ENTRY)).toBeInTheDocument(); + }); + + it("resyncs immediately after the Activity Feed stream reconnects", async () => { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask({ + id: "FN-FEED-1", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + }) as never); + + renderModal({ task: strippedSnapshot(), initialTab: "logs" }); + expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(1); + + act(() => activityFeedSubscription().options.onReconnect?.()); + await waitFor(() => expect(vi.mocked(fetchTaskDetail)).toHaveBeenCalledTimes(2)); + }); + + it("keeps the journal through a stripped snapshot in an embedded host", async () => { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask({ + id: "FN-FEED-1", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + }) as never); + + const { rerender } = renderEmbedded(); + expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); + }); + + it("keeps the journal through a stripped snapshot in the mobile Feed", async () => { + const originalInnerWidth = window.innerWidth; + const originalMatchMedia = window.matchMedia; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + try { + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask({ + id: "FN-FEED-1", + log: [{ timestamp: "2026-08-26T08:45:40.000Z", action: SERVER_ENTRY }], + }) as never); + + const { rerender } = renderModal({ task: strippedSnapshot(), initialTab: "logs" }); + expect(await screen.findByText(SERVER_ENTRY)).toBeInTheDocument(); + rerender( + , + ); + + expect(screen.getByText(SERVER_ENTRY)).toBeInTheDocument(); + expect(screen.queryByText("(no activity)")).not.toBeInTheDocument(); + } finally { + Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); + Object.defineProperty(window, "matchMedia", { configurable: true, value: originalMatchMedia }); + } }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 4b124bb44d..45a8d88dac 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -11,7 +11,10 @@ import { clearAuthToken } from "../../auth"; const taskDetailSseSubscriptions = vi.hoisted(() => [] as Array<{ url: string; - options: { events?: Record void> }; + options: { + events?: Record void>; + onReconnect?: () => void; + }; }>); export { taskDetailSseSubscriptions }; @@ -39,7 +42,10 @@ export function expectSingleStatsRuntimeStatus(status: string): void { } vi.mock("../../sse-bus", () => ({ - subscribeSse: vi.fn((url: string, options: { events?: Record void> }) => { + subscribeSse: vi.fn((url: string, options: { + events?: Record void>; + onReconnect?: () => void; + }) => { taskDetailSseSubscriptions.push({ url, options }); return vi.fn(); }), diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index 8784d03160..51e2134049 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -1606,26 +1606,30 @@ describe("TaskDetailModal delete affordance", () => { describe("TaskDetailModal in-review stall diagnostics", () => { it("renders diagnostic row and jumps to highlighted activity entry", async () => { const user = userEvent.setup(); + const task = makeTask({ + column: "in-review", + /* + FNXC:InReviewStallBadge 2026-07-26-18:20: + Fixture repointed off `merge-blocker`, which is now badge-suppressed. This case guards the + diagnostics row and its jump-to-activity-entry behavior — not any one stall code — so it + needs a code that still surfaces. + */ + inReviewStall: { + code: "transient-merge-status-no-owner", + reason: "Workflow pre-merge check failed", + observedAt: "2026-05-13T00:00:00.000Z", + }, + log: [ + { timestamp: "2026-05-13T00:01:00.000Z", action: "In-review stall surfaced [transient-merge-status-no-owner]: Workflow pre-merge check failed" }, + ], + }); + const { fetchTaskDetail } = await import("../../api"); + vi.mocked(fetchTaskDetail).mockResolvedValue(task); + render( { }); }); +describe("activity journal retention", () => { + const journal = [{ timestamp: "2026-08-05T10:00:00.000Z", action: "Created task" }]; + const current = { + ...createInProgressTask("FN-JOURNAL", Date.parse("2026-08-05T10:00:00.000Z")), + prompt: "# Complete task detail", + log: journal, + } as Task; + + it("retains a populated journal for a newer stripped payload that carries a prompt", () => { + const incoming = { + ...current, + prompt: "# Board prompt", + log: [], + updatedAt: "2026-08-05T10:01:00.000Z", + } as Task; + + expect(mergeTaskSnapshot(current, incoming).log).toEqual(journal); + }); + + it("retains a populated journal for a stripped payload with no prompt", () => { + const incoming = { + ...current, + prompt: undefined, + log: [], + updatedAt: "2026-08-05T10:01:00.000Z", + } as Task; + + expect(mergeTaskSnapshot(current, incoming).log).toEqual(journal); + }); + + it("retains a populated journal for an authoritative task:moved payload", () => { + const incoming = { + ...current, + column: "in-review", + columnMovedAt: "2026-08-05T10:01:00.000Z", + log: [], + updatedAt: "2026-08-05T10:01:00.000Z", + } as Task; + + expect(mergeTaskSnapshot(current, incoming, { authoritativeMove: true }).log).toEqual(journal); + }); + + it("uses a populated authoritative detail journal", () => { + const detailJournal = [{ timestamp: "2026-08-05T10:01:00.000Z", action: "Updated task" }]; + const incoming = { ...current, log: detailJournal, updatedAt: "2026-08-05T10:01:00.000Z" } as Task; + + expect(mergeTaskSnapshot(current, incoming, { fullSnapshot: true }).log).toEqual(detailJournal); + }); + + it("allows an authoritative empty detail journal to clear a populated cached journal", () => { + const incoming = { ...current, log: [], updatedAt: "2026-08-05T10:01:00.000Z" } as Task; + + expect(mergeTaskSnapshot(current, incoming, { fullSnapshot: true }).log).toEqual([]); + }); + + it("uses an older populated authoritative detail journal over an empty stripped row", () => { + const emptyCurrent = { ...current, log: [], updatedAt: "2026-08-05T10:01:00.000Z" } as Task; + const olderDetail = { ...current, updatedAt: "2026-08-05T10:00:00.000Z" } as Task; + + expect(mergeTaskSnapshot(emptyCurrent, olderDetail, { fullSnapshot: true }).log).toEqual(journal); + }); +}); + describe("useTasks hydration freshness (dataAsOfMs)", () => { it("reports the envelope savedAt, not now, on the first render after a 2-hour discard", () => { const savedAt = seedSnapshot([createInProgressTask("FN-1", Date.now() - TWO_HOURS_MS)], TWO_HOURS_MS); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index b795468c42..3df7bba4d1 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -440,7 +440,25 @@ export function mergeTaskSnapshot( if ("prompt" in current && incoming.prompt === undefined) { merged.prompt = current.prompt; - merged.log = current.log; + } + + /* + FNXC:TaskActivityFeed 2026-08-28-00:13: + FN-205 found `stripTaskListHeavyFields` emits `log: []` for every slim SSE/list task payload. The + task journal is append-only and trimmed server-side, so an absent or empty slim log is never evidence + that a populated journal was cleared. Retain it independently of prompt presence; board-to-board + merges remain inert because both slim rows have empty journals. + + A marked full detail snapshot is authoritative, including an honestly empty journal. It also adopts a + populated journal over an empty current row even when its clock is older, because the empty stripped + row is not competing journal evidence. + */ + const currentLog = current.log; + const incomingLog = incoming.log; + if (options.fullSnapshot === true) { + merged.log = incomingLog; + } else if (currentLog && currentLog.length > 0 && (!incomingLog || incomingLog.length === 0)) { + merged.log = currentLog; } return merged as T; diff --git a/packages/dashboard/app/utils/__tests__/resumeInstrumentation.test.ts b/packages/dashboard/app/utils/__tests__/resumeInstrumentation.test.ts index 7c0b355d79..9b8c8f1581 100644 --- a/packages/dashboard/app/utils/__tests__/resumeInstrumentation.test.ts +++ b/packages/dashboard/app/utils/__tests__/resumeInstrumentation.test.ts @@ -2,26 +2,14 @@ * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RESUME_TRIGGERS } from "../../../src/shared/resume-triggers"; import { clearResumeEvents, getResumeEvents, recordResumeEvent, setResumeInstrumentationEnabled, - type ResumeTrigger, } from "../resumeInstrumentation"; -const triggers: ResumeTrigger[] = [ - "visibility", - "pageshow", - "sse-error", - "sse-reconnect", - "sse-open", - "remount", - "route-active", - "route-inactive", - "project-context-change", -]; - describe("resumeInstrumentation", () => { beforeEach(() => { vi.useFakeTimers(); @@ -35,11 +23,27 @@ describe("resumeInstrumentation", () => { }); it("records all trigger values", () => { - for (const trigger of triggers) { + for (const trigger of RESUME_TRIGGERS) { recordResumeEvent({ view: "test", trigger, replayAttempted: false }); } - expect(getResumeEvents().map((event) => event.trigger)).toEqual(triggers); + expect(getResumeEvents().map((event) => event.trigger)).toEqual(RESUME_TRIGGERS); + }); + + it("posts a recorded focus event to resume diagnostics", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + + recordResumeEvent({ view: "useTasks", trigger: "focus", replayAttempted: false }); + await vi.runAllTimersAsync(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/api/diagnostics/resume-events", + expect.objectContaining({ + body: expect.stringContaining('"trigger":"focus"'), + method: "POST", + }), + ); }); it("computes gapMs with fake timers", () => { diff --git a/packages/dashboard/app/utils/resumeInstrumentation.ts b/packages/dashboard/app/utils/resumeInstrumentation.ts index 404ddfb5b0..e184f04bfa 100644 --- a/packages/dashboard/app/utils/resumeInstrumentation.ts +++ b/packages/dashboard/app/utils/resumeInstrumentation.ts @@ -1,21 +1,7 @@ +import { type ResumeTrigger } from "../../src/shared/resume-triggers"; import { pushTrace } from "./dashboardTraceBuffer"; -/* -FNXC:DashboardResume 2026-08-05-18:08: -Focus-only tab returns are a supported card-resume path distinct from visibility and pageshow. Keep the -trace vocabulary explicit so operators can distinguish its authoritative revalidation from SSE recovery. -*/ -export type ResumeTrigger = - | "visibility" - | "focus" - | "pageshow" - | "sse-error" - | "sse-reconnect" - | "sse-open" - | "remount" - | "route-active" - | "route-inactive" - | "project-context-change"; +export type { ResumeTrigger }; export type ResumeEvent = { ts: string; diff --git a/packages/dashboard/src/routes/__tests__/register-diagnostics-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-diagnostics-routes.test.ts index d2a7322a13..3b98e17418 100644 --- a/packages/dashboard/src/routes/__tests__/register-diagnostics-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-diagnostics-routes.test.ts @@ -3,6 +3,7 @@ import express from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { request as performRequest } from "../../test-request.js"; +import { RESUME_TRIGGERS } from "../../shared/resume-triggers.js"; import { __resetResumeDiagnosticsForTests, __setResumeDiagnosticsCapForTests, @@ -73,6 +74,34 @@ describe("register-diagnostics-routes", () => { expect(response.body).toEqual({ ok: true, accepted: 1 }); }); + it.each(RESUME_TRIGGERS)("accepts the shared %s resume trigger", async (trigger) => { + const { app } = createApp(); + const response = await performRequest( + app, + "POST", + "/api/diagnostics/resume-events", + JSON.stringify({ events: [{ ...validEvent, trigger }] }), + { "Content-Type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true, accepted: 1 }); + }); + + it("accepts focus, the tab-return trigger reported by operators", async () => { + const { app } = createApp(); + const response = await performRequest( + app, + "POST", + "/api/diagnostics/resume-events", + JSON.stringify({ events: [{ ...validEvent, trigger: "focus" }] }), + { "Content-Type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true, accepted: 1 }); + }); + it("rejects invalid payloads", async () => { const { app } = createApp(); @@ -94,6 +123,16 @@ describe("register-diagnostics-routes", () => { ); expect(badTrigger.status).toBe(400); + const mixedTriggerBatch = await performRequest( + app, + "POST", + "/api/diagnostics/resume-events", + JSON.stringify({ events: [validEvent, { ...validEvent, trigger: "unknown" }] }), + { "Content-Type": "application/json" }, + ); + expect(mixedTriggerBatch.status).toBe(400); + expect(mixedTriggerBatch.body).toEqual({ error: "Invalid resume event entry" }); + const hugeDetail = await performRequest( app, "POST", diff --git a/packages/dashboard/src/routes/register-diagnostics-routes.ts b/packages/dashboard/src/routes/register-diagnostics-routes.ts index 6c3fcd2335..a8e06732bd 100644 --- a/packages/dashboard/src/routes/register-diagnostics-routes.ts +++ b/packages/dashboard/src/routes/register-diagnostics-routes.ts @@ -1,21 +1,10 @@ +import { isResumeTrigger } from "../shared/resume-triggers.js"; import type { ApiRouteRegistrar } from "./types.js"; let resumeRingCap = 5_000; const ACCEPT_CAP = 100; const DETAIL_CAP_BYTES = 4 * 1024; -const triggers = new Set([ - "visibility", - "pageshow", - "sse-error", - "sse-reconnect", - "sse-open", - "remount", - "route-active", - "route-inactive", - "project-context-change", -]); - type ResumeEvent = { ts: string; view: string; @@ -53,7 +42,7 @@ function validateEvent(event: unknown): event is ResumeEvent { const candidate = event as Record; if (!isIsoDate(candidate.ts)) return false; if (typeof candidate.view !== "string" || candidate.view.length === 0 || candidate.view.length > 64) return false; - if (typeof candidate.trigger !== "string" || !triggers.has(candidate.trigger)) return false; + if (!isResumeTrigger(candidate.trigger)) return false; if (typeof candidate.replayAttempted !== "boolean") return false; if (candidate.detail !== undefined) { diff --git a/packages/dashboard/src/shared/resume-triggers.ts b/packages/dashboard/src/shared/resume-triggers.ts new file mode 100644 index 0000000000..72e1c52dbb --- /dev/null +++ b/packages/dashboard/src/shared/resume-triggers.ts @@ -0,0 +1,28 @@ +/* +FNXC:DashboardResume 2026-08-05-18:08: +Focus-only tab returns are a supported card-resume path distinct from visibility and pageshow. Keep the +trace vocabulary explicit so operators can distinguish its authoritative revalidation from SSE recovery. + +FNXC:DashboardResume 2026-08-28-00:13: +FN-205 found the client emitting the supported `focus` trigger while the diagnostics route used a stale +local list and rejected the entire batch. This browser- and server-safe registry is the single vocabulary +so valid resume diagnostics cannot drift between the two bundles. +*/ +export const RESUME_TRIGGERS = [ + "visibility", + "focus", + "pageshow", + "sse-error", + "sse-reconnect", + "sse-open", + "remount", + "route-active", + "route-inactive", + "project-context-change", +] as const; + +export type ResumeTrigger = (typeof RESUME_TRIGGERS)[number]; + +export function isResumeTrigger(value: unknown): value is ResumeTrigger { + return typeof value === "string" && (RESUME_TRIGGERS as readonly string[]).includes(value); +}