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() + }) +})