From a251d4105ed2844cf8a5e0d479bd3cfd8021c103 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 12:24:28 -0700 Subject: [PATCH] FN-8303: delay task chat loading indicator Prevent the Activity Live transcript spinner from flashing during default task opens or task selection. - Delay the empty transcript loading indicator until a request remains pending. - Scope delayed spinner state to each task to protect reused split-detail chat instances. - Cover fast initial loads, overlay and embedded defaults, and task switching. Files changed: packages/dashboard/app/components/TaskChatTab.tsx | 35 ++++++++++++++--- .../app/components/__tests__/TaskChatTab.test.tsx | 44 +++++++++++++++++++++- .../TaskDetailModal.task-activity-chat.test.tsx | 41 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-8303 Fusion-Task-Lineage: 34af8f5e-9e47-4ff6-bf08-b3bda0744dae Co-authored-by: Fusion (runfusion.ai) --- .../dashboard/app/components/TaskChatTab.tsx | 35 ++++++++++++--- .../components/__tests__/TaskChatTab.test.tsx | 44 ++++++++++++++++++- ...askDetailModal.task-activity-chat.test.tsx | 41 +++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 27c5c70007..9c2350487d 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -1,5 +1,5 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core"; -import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; @@ -53,6 +53,7 @@ type TaskChatToolGroupRow = const BOTTOM_FOLLOW_THRESHOLD = 48; const TOP_LOAD_THRESHOLD = 48; +const INITIAL_LOADING_INDICATOR_DELAY_MS = 150; function isTranscriptNearBottom(container: HTMLElement): boolean { return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; @@ -622,6 +623,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated, const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); + const [loadingIndicatorTaskId, setLoadingIndicatorTaskId] = useState(null); const sendingRef = useRef(false); const [optimisticMessages, setOptimisticMessages] = useState([]); const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); @@ -661,6 +663,27 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated, : t("taskChat.activePlaceholder", "Steer the currently executing agent"); const canSend = draft.trim().length > 0 && !sending; + useEffect(() => { + if (!loading || transcriptItemCount > 0) { + setLoadingIndicatorTaskId(null); + return; + } + + /* + * FNXC:TaskDetailChat 2026-07-18-12:21: + * FN-8303 browser tracing showed that omitted-tab Activity → Live briefly paints + * “Loading agent output…” before its already-populated initial log response arrives. + * Delay that indicator so a fast default-open keeps the stable transcript shell rather + * than flashing spinner-to-content; slow requests still receive explicit feedback. + * Bind the delayed state to its task so a reused List split-detail instance cannot paint + * a prior task’s slow-request spinner while its newly selected task initializes. + */ + const timer = window.setTimeout(() => setLoadingIndicatorTaskId(task.id), INITIAL_LOADING_INDICATOR_DELAY_MS); + return () => window.clearTimeout(timer); + }, [loading, task.id, transcriptItemCount]); + + const showLoadingIndicator = loadingIndicatorTaskId === task.id; + const resizeComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -959,10 +982,12 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated, ) : null} {loading && transcriptItemCount === 0 ? ( -
-
+ showLoadingIndicator ? ( +
+
+ ) : null ) : transcriptItemCount === 0 ? (
{t("taskChat.emptyAgentOutput", "No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")}
) : ( diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index e838b6941b..8f7209fcdc 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -395,11 +395,14 @@ describe("TaskChatTab", () => { expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/); }); - it("renders loading state without timestamp shells or invalid-date text", () => { + it("renders delayed loading state without timestamp shells or invalid-date text", () => { + vi.useFakeTimers(); mockLogs([], true); render(); const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).queryByText("Loading agent output…")).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(150)); expect(within(transcript).getByText("Loading agent output…")).toBeTruthy(); expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument(); expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument(); @@ -407,6 +410,37 @@ describe("TaskChatTab", () => { expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/); }); + it("FN-8303: does not paint the loading spinner before a fast initial transcript response", () => { + vi.useFakeTimers(); + const loadedEntries = [makeEntry({ agent: "executor", text: "initial loaded output" })]; + mockedUseAgentLogs + .mockReturnValueOnce({ entries: [], loading: true, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 0, loadingMore: false }) + .mockReturnValueOnce({ entries: loadedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }); + + const { rerender } = render(); + expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText("initial loaded output")).toBeVisible(); + expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument(); + }); + + it("FN-8303: does not reuse a prior task's slow-request spinner after task selection", () => { + vi.useFakeTimers(); + mockLogs([], true); + const { rerender } = render(); + + act(() => vi.advanceTimersByTime(150)); + expect(screen.getByText("Loading agent output…")).toBeVisible(); + + rerender(); + + expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(150)); + expect(screen.getByText("Loading agent output…")).toBeVisible(); + }); + it("renders the collapsed icon-only expand toggle inside the chat view and calls the toggle handler", () => { const onToggleExpanded = vi.fn(); render(); @@ -437,13 +471,15 @@ describe("TaskChatTab", () => { expect(toggle).not.toHaveTextContent("Expand"); }); - it("renders the icon-only expand toggle while the transcript is loading", () => { + it("renders the icon-only expand toggle while a slow transcript request is loading", () => { + vi.useFakeTimers(); mockLogs([], true); render(); const toggle = screen.getByTestId("task-chat-expand-toggle"); expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle); expect(toggle).not.toHaveTextContent("Expand"); + act(() => vi.advanceTimersByTime(150)); expect(screen.getByText("Loading agent output…")).toBeInTheDocument(); }); @@ -631,8 +667,10 @@ describe("TaskChatTab", () => { }); it("keeps List View split chat empty and loading states free of header shells", () => { + vi.useFakeTimers(); mockLogs([], true); const loading = renderListSplitTaskChat(); + act(() => vi.advanceTimersByTime(150)); expect(screen.getByText(/Loading agent output/)).toBeVisible(); expect(document.querySelector(".task-chat-group-header")).not.toBeInTheDocument(); expect(document.querySelector(".task-chat-group-bubbles")).not.toBeInTheDocument(); @@ -1512,8 +1550,10 @@ describe("TaskChatTab", () => { }); it("does not render the jump-to-bottom button for loading or empty transcripts", () => { + vi.useFakeTimers(); mockLogs([], true); const loading = render(); + act(() => vi.advanceTimersByTime(150)); expect(screen.getByText(/Loading agent output/)).toBeVisible(); expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); loading.unmount(); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx index 12311a5a53..e059433504 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx @@ -155,6 +155,47 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => { expect(screen.getByText("raw executor line")).toBeInTheDocument(); }); + it("FN-8303: suppresses the initial Live loading flash for overlay and embedded Activity defaults", () => { + mockedUseAgentLogs.mockReturnValue({ + entries: [], + loading: true, + clear: vi.fn(), + loadMore: vi.fn(async () => {}), + hasMore: false, + total: 0, + loadingMore: false, + }); + + const overlay = renderModal({ taskDetailChatFirst: false }); + expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); + expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); + expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument(); + overlay.unmount(); + + const embedded = render( + , + ); + expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); + expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); + expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument(); + embedded.unmount(); + + renderModal({ taskDetailChatFirst: true }); + expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active"); + expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument(); + expect(screen.queryByTestId("task-chat-transcript")).not.toBeInTheDocument(); + }); + it("portals the mobile Activity view menu outside the tab scroller while keeping tabs and content visible", () => { const originalInnerWidth = window.innerWidth; const originalInnerHeight = window.innerHeight;