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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user