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"; +}