diff --git a/webview-ui/src/components/chat/BrowserSessionRow.tsx b/webview-ui/src/components/chat/BrowserSessionRow.tsx
index cdb15315dd..50fb9fda63 100644
--- a/webview-ui/src/components/chat/BrowserSessionRow.tsx
+++ b/webview-ui/src/components/chat/BrowserSessionRow.tsx
@@ -20,6 +20,7 @@ interface BrowserSessionRowProps {
isExpanded: (messageTs: number) => boolean
onToggleExpand: (messageTs: number) => void
lastModifiedMessage?: ClineMessage
+ modifiedMessages: ClineMessage[]
isLast: boolean
onHeightChange: (isTaller: boolean) => void
isStreaming: boolean
@@ -423,6 +424,7 @@ const BrowserSessionRowContent = ({
isExpanded,
onToggleExpand,
lastModifiedMessage,
+ modifiedMessages,
isLast,
setMaxActionHeight,
isStreaming,
@@ -453,6 +455,7 @@ const BrowserSessionRowContent = ({
onToggleExpand(message.ts)
}}
lastModifiedMessage={lastModifiedMessage}
+ modifiedMessages={modifiedMessages}
isLast={isLast}
isStreaming={isStreaming}
/>
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index 43824c5902..9ba6866ef0 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -43,6 +43,7 @@ import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
interface ChatRowProps {
message: ClineMessage
lastModifiedMessage?: ClineMessage
+ modifiedMessages: ClineMessage[]
isExpanded: boolean
isLast: boolean
isStreaming: boolean
@@ -93,6 +94,7 @@ export default ChatRow
export const ChatRowContent = ({
message,
lastModifiedMessage,
+ modifiedMessages,
isExpanded,
isLast,
isStreaming,
@@ -129,10 +131,43 @@ export const ChatRowContent = ({
? lastModifiedMessage?.text
: undefined
- const isCommandExecuting =
- isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
+ const isCommandExecuting = useMemo(() => {
+ if (
+ !isLast ||
+ lastModifiedMessage?.ask !== "command" ||
+ !lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
+ ) {
+ return false
+ }
+
+ // Check if there's a subsequent message indicating command completion
+ // Look for any message after the command that would indicate it's finished
+ const commandTs = lastModifiedMessage.ts
+ const hasCompletionMessage = modifiedMessages.some(
+ (msg) =>
+ msg.ts > commandTs &&
+ (msg.say === "api_req_finished" ||
+ msg.say === "completion_result" ||
+ msg.ask === "completion_result" ||
+ (msg.say === "command_output" && !msg.partial)),
+ )
+
+ return !hasCompletionMessage
+ }, [isLast, lastModifiedMessage, modifiedMessages])
+
+ const isMcpServerResponding = useMemo(() => {
+ if (!isLast || lastModifiedMessage?.say !== "mcp_server_request_started") {
+ return false
+ }
+
+ // Check if there's a subsequent MCP response message
+ const mcpRequestTs = lastModifiedMessage.ts
+ const hasMcpResponse = modifiedMessages.some(
+ (msg) => msg.ts > mcpRequestTs && msg.say === "mcp_server_response",
+ )
- const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started"
+ return !hasMcpResponse
+ }, [isLast, lastModifiedMessage, modifiedMessages])
const type = message.type === "ask" ? message.ask : message.say
@@ -212,21 +247,31 @@ export const ChatRowContent = ({
/>
)
+ // Check if this API request has actually finished by looking for completion indicators
+ const apiRequestFinished = cost !== null && cost !== undefined
+ const apiRequestCancelled = apiReqCancelReason !== null && apiReqCancelReason !== undefined
+ const apiRequestFailed = apiRequestFailedMessage !== undefined
+
+ // Only show spinner if the request is truly still in progress
+ const shouldShowSpinner = !apiRequestFinished && !apiRequestCancelled && !apiRequestFailed
+
return [
- apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
+ apiRequestCancelled ? (
apiReqCancelReason === "user_cancelled" ? (
getIconSpan("error", cancelledColor)
) : (
getIconSpan("error", errorColor)
)
- ) : cost !== null && cost !== undefined ? (
+ ) : apiRequestFinished ? (
getIconSpan("check", successColor)
- ) : apiRequestFailedMessage ? (
+ ) : apiRequestFailed ? (
getIconSpan("error", errorColor)
- ) : (
+ ) : shouldShowSpinner ? (
+ ) : (
+ getIconSpan("check", successColor)
),
- apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
+ apiRequestCancelled ? (
apiReqCancelReason === "user_cancelled" ? (
{t("chat:apiRequest.cancelled")}
@@ -236,12 +281,14 @@ export const ChatRowContent = ({
{t("chat:apiRequest.streamingFailed")}
)
- ) : cost !== null && cost !== undefined ? (
+ ) : apiRequestFinished ? (
{t("chat:apiRequest.title")}
- ) : apiRequestFailedMessage ? (
+ ) : apiRequestFailed ? (
{t("chat:apiRequest.failed")}
- ) : (
+ ) : shouldShowSpinner ? (
{t("chat:apiRequest.streaming")}
+ ) : (
+ {t("chat:apiRequest.title")}
),
]
case "followup":
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index 90901c84e9..bd2b33d60d 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -1197,6 +1197,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction expandedRows[messageTs] ?? false}
@@ -1218,6 +1219,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({
+ useAppTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock the translation hook
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ Trans: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+// Mock the extension state context
+jest.mock("@src/context/ExtensionStateContext", () => ({
+ useExtensionState: () => ({
+ mcpServers: [],
+ alwaysAllowMcp: false,
+ currentCheckpoint: null,
+ }),
+}))
+
+// Mock the clipboard hook
+jest.mock("@src/utils/clipboard", () => ({
+ useCopyToClipboard: () => ({
+ copyWithFeedback: jest.fn(),
+ }),
+}))
+
+// Mock the ProgressIndicator component
+jest.mock("../ProgressIndicator", () => ({
+ ProgressIndicator: () => Loading...
,
+}))
+
+describe("ChatRow Spinner Logic", () => {
+ const baseMessage: ClineMessage = {
+ ts: 1000,
+ type: "say",
+ say: "api_req_started",
+ text: JSON.stringify({ cost: undefined, cancelReason: undefined }),
+ }
+
+ const baseProps = {
+ isExpanded: false,
+ isLast: true,
+ isStreaming: false,
+ onToggleExpand: jest.fn(),
+ onSuggestionClick: jest.fn(),
+ onBatchFileResponse: jest.fn(),
+ }
+
+ it("should show spinner for ongoing API request", () => {
+ const modifiedMessages: ClineMessage[] = [baseMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId("progress-indicator")).toBeInTheDocument()
+ })
+
+ it("should not show spinner when API request is finished", () => {
+ const finishedMessage: ClineMessage = {
+ ...baseMessage,
+ text: JSON.stringify({ cost: 0.001, cancelReason: undefined }),
+ }
+ const modifiedMessages: ClineMessage[] = [finishedMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId("progress-indicator")).not.toBeInTheDocument()
+ })
+
+ it("should show spinner for ongoing command execution", () => {
+ const commandMessage: ClineMessage = {
+ ts: 1000,
+ type: "ask",
+ ask: "command",
+ text: "echo 'test'\n\n--- COMMAND OUTPUT ---\n",
+ }
+ const modifiedMessages: ClineMessage[] = [commandMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId("progress-indicator")).toBeInTheDocument()
+ })
+
+ it("should not show spinner when command execution is completed", () => {
+ const commandMessage: ClineMessage = {
+ ts: 1000,
+ type: "ask",
+ ask: "command",
+ text: "echo 'test'\n\n--- COMMAND OUTPUT ---\n",
+ }
+ const completionMessage: ClineMessage = {
+ ts: 1001,
+ type: "say",
+ say: "api_req_finished",
+ }
+ const modifiedMessages: ClineMessage[] = [commandMessage, completionMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId("progress-indicator")).not.toBeInTheDocument()
+ })
+
+ it("should show spinner for ongoing MCP server request", () => {
+ const mcpMessage: ClineMessage = {
+ ts: 1000,
+ type: "say",
+ say: "mcp_server_request_started",
+ }
+ const modifiedMessages: ClineMessage[] = [mcpMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId("progress-indicator")).toBeInTheDocument()
+ })
+
+ it("should not show spinner when MCP server request is completed", () => {
+ const mcpRequestMessage: ClineMessage = {
+ ts: 1000,
+ type: "say",
+ say: "mcp_server_request_started",
+ }
+ const mcpResponseMessage: ClineMessage = {
+ ts: 1001,
+ type: "say",
+ say: "mcp_server_response",
+ }
+ const modifiedMessages: ClineMessage[] = [mcpRequestMessage, mcpResponseMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId("progress-indicator")).not.toBeInTheDocument()
+ })
+
+ it("should not show spinner when API request is cancelled", () => {
+ const cancelledMessage: ClineMessage = {
+ ...baseMessage,
+ text: JSON.stringify({ cost: undefined, cancelReason: "user_cancelled" }),
+ }
+ const modifiedMessages: ClineMessage[] = [cancelledMessage]
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId("progress-indicator")).not.toBeInTheDocument()
+ })
+})