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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 12:24:28 -07:00
parent ed61fced5a
commit a251d4105e
3 changed files with 113 additions and 7 deletions

View File

@@ -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<string | null>(null);
const sendingRef = useRef(false);
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
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,
</div>
) : null}
{loading && transcriptItemCount === 0 ? (
<div className="task-chat-empty" role="status">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>{t("taskChat.loadingAgentOutput", "Loading agent output…")}</span>
</div>
showLoadingIndicator ? (
<div className="task-chat-empty" role="status">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>{t("taskChat.loadingAgentOutput", "Loading agent output…")}</span>
</div>
) : null
) : transcriptItemCount === 0 ? (
<div className="task-chat-empty">{t("taskChat.emptyAgentOutput", "No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")}</div>
) : (

View File

@@ -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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
expect(screen.queryByText("Loading agent output…")).not.toBeInTheDocument();
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask({ id: "FN-8303-first" })} active addToast={vi.fn()} />);
act(() => vi.advanceTimersByTime(150));
expect(screen.getByText("Loading agent output…")).toBeVisible();
rerender(<TaskChatTab task={makeTask({ id: "FN-8303-next" })} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={onToggleExpanded} />);
@@ -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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
act(() => vi.advanceTimersByTime(150));
expect(screen.getByText(/Loading agent output/)).toBeVisible();
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
loading.unmount();

View File

@@ -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(
<TaskDetailContent
task={makeTask({ id: "FN-8303-embedded", column: "in-progress" as any, log: [], steeringComments: [], plannerOversightLevel: "off" })}
embedded
onRequestClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
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();
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;