Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export interface WebviewMessage {
| "createCommand"
| "insertTextIntoTextarea"
| "showMdmAuthRequiredNotification"
| "apiStatusConfig"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
Expand Down
117 changes: 117 additions & 0 deletions webview-ui/src/components/chat/AnimatedStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useEffect, useState, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useExtensionState } from "@src/context/ExtensionStateContext"

interface AnimatedStatusIndicatorProps {
isStreaming: boolean
cost?: number | null
cancelReason?: string | null
apiRequestFailedMessage?: string
streamingFailedMessage?: string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this streamingFailedMessage prop intentional? It's defined but never used in the component. Could we either use it or remove it to avoid confusion?

}

const DEFAULT_STATUS_TEXTS = ["Generating...", "Thinking...", "Working on it...", "Processing...", "Analyzing..."]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These default constants are duplicated in AnimatedStatusSettings.tsx. Could we extract them to a shared constants file to maintain a single source of truth?


const DEFAULT_EMOJIS = [
"🤔", // Thinking
"🧠", // Brainstorming
"⏳", // Loading
"✨", // Magic
"🔮", // Summoning
"💭", // Thought bubble
"⚡", // Lightning
"🎯", // Target
]

export const AnimatedStatusIndicator: React.FC<AnimatedStatusIndicatorProps> = ({
isStreaming,
cost,
cancelReason,
apiRequestFailedMessage,
}) => {
const { t } = useTranslation()
const { apiStatusConfig = {} } = useExtensionState()

// Configuration with defaults
const config = useMemo(
() => ({
enabled: apiStatusConfig.enabled !== false,
statusTexts:
apiStatusConfig.customTexts && apiStatusConfig.customTexts.length > 0
? apiStatusConfig.customTexts
: DEFAULT_STATUS_TEXTS,
emojisEnabled: apiStatusConfig.emojisEnabled === true,
emojis:
apiStatusConfig.customEmojis && apiStatusConfig.customEmojis.length > 0
? apiStatusConfig.customEmojis
: DEFAULT_EMOJIS,
randomMode: apiStatusConfig.randomMode !== false,
cycleInterval: apiStatusConfig.cycleInterval || 5000, // 5 seconds default
}),
[apiStatusConfig],
)

const [currentTextIndex, setCurrentTextIndex] = useState(0)
const [currentEmojiIndex, setCurrentEmojiIndex] = useState(0)

// Cycle through status texts and emojis
useEffect(() => {
if (!config.enabled || !isStreaming || !config.randomMode) return

const interval = setInterval(() => {
setCurrentTextIndex((prev) => (prev + 1) % config.statusTexts.length)

if (config.emojisEnabled) {
setCurrentEmojiIndex((prev) => (prev + 1) % config.emojis.length)
}
}, config.cycleInterval)

return () => clearInterval(interval)
}, [config, isStreaming])

// Determine what text to show
const statusText = useMemo(() => {
if (cancelReason === "user_cancelled") {
return t("chat:apiRequest.cancelled")
}
if (cancelReason) {
return t("chat:apiRequest.streamingFailed")
}
if (cost !== null && cost !== undefined) {
return t("chat:apiRequest.title")
}
if (apiRequestFailedMessage) {
return t("chat:apiRequest.failed")
}
if (isStreaming && config.enabled) {
return config.statusTexts[currentTextIndex]
}
return t("chat:apiRequest.streaming")
}, [cancelReason, cost, apiRequestFailedMessage, isStreaming, config, currentTextIndex, t])

// Don't show animated indicator if request is complete or failed
if (!isStreaming || cost !== null || cancelReason || apiRequestFailedMessage) {
return null
}

// If animation is disabled, return null (ChatRow will show default)
if (!config.enabled) {
return null
}

return (
<div className="flex items-center gap-2">
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding ARIA labels for better accessibility. Screen readers won't know what this animated indicator represents:

Suggested change
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" role="status" aria-label="Loading status">

{config.emojisEnabled && (
<span className="text-base animate-pulse-subtle">{config.emojis[currentEmojiIndex]}</span>
)}
<span
className="text-vscode-foreground animate-pulse-subtle"
style={{
fontWeight: "bold",
opacity: 0.9,
}}>
{statusText}
</span>
</div>
)
}
44 changes: 41 additions & 3 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { FollowUpSuggest } from "./FollowUpSuggest"
import { BatchFilePermission } from "./BatchFilePermission"
import { BatchDiffApproval } from "./BatchDiffApproval"
import { ProgressIndicator } from "./ProgressIndicator"
import { AnimatedStatusIndicator } from "./AnimatedStatusIndicator"
import { Markdown } from "./Markdown"
import { CommandExecution } from "./CommandExecution"
import { CommandExecutionError } from "./CommandExecutionError"
Expand Down Expand Up @@ -252,6 +253,14 @@ export const ChatRowContent = ({
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.title")}</span>
) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:apiRequest.failed")}</span>
) : isStreaming ? (
<AnimatedStatusIndicator
isStreaming={true}
cost={cost}
cancelReason={apiReqCancelReason}
apiRequestFailedMessage={apiRequestFailedMessage}
streamingFailedMessage={apiReqStreamingFailedMessage}
/>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.streaming")}</span>
),
Expand All @@ -267,7 +276,18 @@ export const ChatRowContent = ({
default:
return [null, null]
}
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
}, [
type,
isCommandExecuting,
message,
isMcpServerResponding,
apiReqCancelReason,
cost,
apiRequestFailedMessage,
apiReqStreamingFailedMessage,
isStreaming,
t,
])

const headerStyle: React.CSSProperties = {
display: "flex",
Expand Down Expand Up @@ -968,6 +988,9 @@ export const ChatRowContent = ({
/>
)
case "api_req_started":
// Check if we should show the animated indicator
const showAnimated = isLast && !cost && !apiReqCancelReason && !apiRequestFailedMessage

return (
<>
<div
Expand All @@ -987,8 +1010,23 @@ export const ChatRowContent = ({
}}
onClick={handleToggleExpand}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
{icon}
{title}
{showAnimated ? (
<>
<ProgressIndicator />
<AnimatedStatusIndicator
isStreaming={true}
cost={cost}
cancelReason={apiReqCancelReason}
apiRequestFailedMessage={apiRequestFailedMessage}
streamingFailedMessage={apiReqStreamingFailedMessage}
/>
</>
) : (
<>
{icon}
{title}
</>
)}
<VSCodeBadge
style={{ opacity: cost !== null && cost !== undefined && cost > 0 ? 1 : 0 }}>
${Number(cost || 0)?.toFixed(4)}
Expand Down
Loading
Loading