diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx
index 9c2eae5aa..a4f3eb03c 100644
--- a/frontend/src/components/AgentMode.tsx
+++ b/frontend/src/components/AgentMode.tsx
@@ -25,6 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Markdown, ThinkingBlock } from "@/components/markdown";
import {
CHAT_COMPOSER_TEXTAREA_CLASS,
+ ChatAssistantPendingTurn,
ChatAssistantTurn,
ChatComposerSurface,
ChatDesktopConversationHeader,
@@ -70,7 +71,8 @@ import {
coalesceAdjacentThinkingItems,
groupAgentTimelineItems,
hasAgentUserMessage,
- hasRenderableThinkingText
+ hasRenderableThinkingText,
+ shouldShowAgentAssistantLoader
} from "@/services/agentTimeline";
import {
DEFAULT_AGENT_MODEL,
@@ -1589,6 +1591,7 @@ export function AgentMode({ userId }: { userId: string }) {
) : (
@@ -2599,16 +2602,19 @@ function AgentModelSelector({
function AgentTimeline({
items,
+ isResponsePending,
isRunActive,
onPermissionDecision
}: {
items: AgentTimelineItem[];
+ isResponsePending: boolean;
isRunActive: boolean;
onPermissionDecision: (item: AgentTimelineItem, decision: AgentPermissionDecision) => void;
}) {
const visibleItems = coalesceAdjacentThinkingItems(items).filter(isRenderableTimelineItem);
const turns = groupAgentTimelineItems(visibleItems);
const activeThinkingItemId = activeAgentThinkingItemId(visibleItems, isRunActive);
+ const showAssistantLoader = shouldShowAgentAssistantLoader(turns, isResponsePending);
return (
@@ -2634,6 +2640,7 @@ function AgentTimeline({
);
})}
+ {showAssistantLoader ? : null}
);
}
diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx
index ac797fe67..7e875eb1e 100644
--- a/frontend/src/components/UnifiedChat.tsx
+++ b/frontend/src/components/UnifiedChat.tsx
@@ -34,6 +34,7 @@ import { DEFAULT_MODEL_ID, getInitialWebSearchEnabled } from "@/state/LocalState
import { Markdown, ThinkingBlock } from "@/components/markdown";
import {
CHAT_COMPOSER_TEXTAREA_CLASS,
+ ChatAssistantPendingTurn,
ChatAssistantTurn,
ChatComposerSurface,
ChatDesktopConversationHeader,
@@ -1387,15 +1388,7 @@ const MessageList = memo(
})}
{/* Loading indicator - only show while waiting for the first assistant item (TTFT) */}
- {shouldShowInitialAssistantLoader && (
-
-
-
- )}
+ {shouldShowInitialAssistantLoader && }
>
);
}
diff --git a/frontend/src/components/chat/ChatTurn.tsx b/frontend/src/components/chat/ChatTurn.tsx
index f58fd5b23..cbf575d8d 100644
--- a/frontend/src/components/chat/ChatTurn.tsx
+++ b/frontend/src/components/chat/ChatTurn.tsx
@@ -77,6 +77,18 @@ export function ChatAssistantTurn({ children, actions, containerRef, className }
);
}
+export function ChatAssistantPendingTurn() {
+ return (
+
+
+
+ );
+}
+
export function ChatDesktopConversationHeader({
title,
isSidebarOpen,
diff --git a/frontend/src/services/agentTimeline.test.ts b/frontend/src/services/agentTimeline.test.ts
index 0cf32a362..12766a60f 100644
--- a/frontend/src/services/agentTimeline.test.ts
+++ b/frontend/src/services/agentTimeline.test.ts
@@ -5,7 +5,8 @@ import {
coalesceAdjacentThinkingItems,
groupAgentTimelineItems,
hasAgentUserMessage,
- hasRenderableThinkingText
+ hasRenderableThinkingText,
+ shouldShowAgentAssistantLoader
} from "./agentTimeline";
function thinking(id: string, text: string): AgentTimelineItem {
@@ -182,3 +183,36 @@ describe("groupAgentTimelineItems", () => {
expect(after[1].id).toBe(before[1].id);
});
});
+
+describe("shouldShowAgentAssistantLoader", () => {
+ const userTurn = {
+ type: "user" as const,
+ id: "user",
+ item: {
+ id: "user",
+ itemType: "message" as const,
+ role: "user" as const,
+ createdMs: 0,
+ merge: "replace" as const
+ }
+ };
+ const assistantTurn = {
+ type: "assistant" as const,
+ id: "assistant-after-user",
+ items: [thinking("thought", "Working")]
+ };
+
+ test("shows while a sent user turn is waiting for its first assistant activity", () => {
+ expect(shouldShowAgentAssistantLoader([userTurn], true)).toBe(true);
+ });
+
+ test("hides when the response is no longer pending or assistant activity has arrived", () => {
+ expect(shouldShowAgentAssistantLoader([userTurn], false)).toBe(false);
+ expect(shouldShowAgentAssistantLoader([userTurn, assistantTurn], true)).toBe(false);
+ });
+
+ test("does not invent an assistant turn without a user message", () => {
+ expect(shouldShowAgentAssistantLoader([], true)).toBe(false);
+ expect(shouldShowAgentAssistantLoader([assistantTurn], true)).toBe(false);
+ });
+});
diff --git a/frontend/src/services/agentTimeline.ts b/frontend/src/services/agentTimeline.ts
index d5ca4c8f8..cd9e203ba 100644
--- a/frontend/src/services/agentTimeline.ts
+++ b/frontend/src/services/agentTimeline.ts
@@ -64,3 +64,10 @@ export function groupAgentTimelineItems(items: AgentTimelineItem[]): AgentTimeli
flushAssistantItems();
return turns;
}
+
+export function shouldShowAgentAssistantLoader(
+ turns: AgentTimelineTurn[],
+ isResponsePending: boolean
+): boolean {
+ return isResponsePending && turns[turns.length - 1]?.type === "user";
+}