Skip to content

Commit 4ed2a3d

Browse files
committed
feat: replace static API Request label with customizable animated indicator
- Add AnimatedStatusIndicator component with pulsing animation - Support customizable status texts and emoji mode - Add settings UI for configuring the animated indicator - Include random mode to cycle through messages - Add CSS animations for subtle pulse effect - Integrate with ChatRow to replace static label Implements #7523
1 parent 1d46bd1 commit 4ed2a3d

File tree

8 files changed

+435
-3
lines changed

8 files changed

+435
-3
lines changed

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export interface WebviewMessage {
212212
| "createCommand"
213213
| "insertTextIntoTextarea"
214214
| "showMdmAuthRequiredNotification"
215+
| "apiStatusConfig"
215216
text?: string
216217
editedMessageContent?: string
217218
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React, { useEffect, useState, useMemo } from "react"
2+
import { useTranslation } from "react-i18next"
3+
import { useExtensionState } from "@src/context/ExtensionStateContext"
4+
5+
interface AnimatedStatusIndicatorProps {
6+
isStreaming: boolean
7+
cost?: number | null
8+
cancelReason?: string | null
9+
apiRequestFailedMessage?: string
10+
streamingFailedMessage?: string
11+
}
12+
13+
const DEFAULT_STATUS_TEXTS = ["Generating...", "Thinking...", "Working on it...", "Processing...", "Analyzing..."]
14+
15+
const DEFAULT_EMOJIS = [
16+
"🤔", // Thinking
17+
"🧠", // Brainstorming
18+
"⏳", // Loading
19+
"✨", // Magic
20+
"🔮", // Summoning
21+
"💭", // Thought bubble
22+
"⚡", // Lightning
23+
"🎯", // Target
24+
]
25+
26+
export const AnimatedStatusIndicator: React.FC<AnimatedStatusIndicatorProps> = ({
27+
isStreaming,
28+
cost,
29+
cancelReason,
30+
apiRequestFailedMessage,
31+
}) => {
32+
const { t } = useTranslation()
33+
const { apiStatusConfig = {} } = useExtensionState()
34+
35+
// Configuration with defaults
36+
const config = useMemo(
37+
() => ({
38+
enabled: apiStatusConfig.enabled !== false,
39+
statusTexts:
40+
apiStatusConfig.customTexts && apiStatusConfig.customTexts.length > 0
41+
? apiStatusConfig.customTexts
42+
: DEFAULT_STATUS_TEXTS,
43+
emojisEnabled: apiStatusConfig.emojisEnabled === true,
44+
emojis:
45+
apiStatusConfig.customEmojis && apiStatusConfig.customEmojis.length > 0
46+
? apiStatusConfig.customEmojis
47+
: DEFAULT_EMOJIS,
48+
randomMode: apiStatusConfig.randomMode !== false,
49+
cycleInterval: apiStatusConfig.cycleInterval || 5000, // 5 seconds default
50+
}),
51+
[apiStatusConfig],
52+
)
53+
54+
const [currentTextIndex, setCurrentTextIndex] = useState(0)
55+
const [currentEmojiIndex, setCurrentEmojiIndex] = useState(0)
56+
57+
// Cycle through status texts and emojis
58+
useEffect(() => {
59+
if (!config.enabled || !isStreaming || !config.randomMode) return
60+
61+
const interval = setInterval(() => {
62+
setCurrentTextIndex((prev) => (prev + 1) % config.statusTexts.length)
63+
64+
if (config.emojisEnabled) {
65+
setCurrentEmojiIndex((prev) => (prev + 1) % config.emojis.length)
66+
}
67+
}, config.cycleInterval)
68+
69+
return () => clearInterval(interval)
70+
}, [config, isStreaming])
71+
72+
// Determine what text to show
73+
const statusText = useMemo(() => {
74+
if (cancelReason === "user_cancelled") {
75+
return t("chat:apiRequest.cancelled")
76+
}
77+
if (cancelReason) {
78+
return t("chat:apiRequest.streamingFailed")
79+
}
80+
if (cost !== null && cost !== undefined) {
81+
return t("chat:apiRequest.title")
82+
}
83+
if (apiRequestFailedMessage) {
84+
return t("chat:apiRequest.failed")
85+
}
86+
if (isStreaming && config.enabled) {
87+
return config.statusTexts[currentTextIndex]
88+
}
89+
return t("chat:apiRequest.streaming")
90+
}, [cancelReason, cost, apiRequestFailedMessage, isStreaming, config, currentTextIndex, t])
91+
92+
// Don't show animated indicator if request is complete or failed
93+
if (!isStreaming || cost !== null || cancelReason || apiRequestFailedMessage) {
94+
return null
95+
}
96+
97+
// If animation is disabled, return null (ChatRow will show default)
98+
if (!config.enabled) {
99+
return null
100+
}
101+
102+
return (
103+
<div className="flex items-center gap-2">
104+
{config.emojisEnabled && (
105+
<span className="text-base animate-pulse-subtle">{config.emojis[currentEmojiIndex]}</span>
106+
)}
107+
<span
108+
className="text-vscode-foreground animate-pulse-subtle"
109+
style={{
110+
fontWeight: "bold",
111+
opacity: 0.9,
112+
}}>
113+
{statusText}
114+
</span>
115+
</div>
116+
)
117+
}

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { FollowUpSuggest } from "./FollowUpSuggest"
3535
import { BatchFilePermission } from "./BatchFilePermission"
3636
import { BatchDiffApproval } from "./BatchDiffApproval"
3737
import { ProgressIndicator } from "./ProgressIndicator"
38+
import { AnimatedStatusIndicator } from "./AnimatedStatusIndicator"
3839
import { Markdown } from "./Markdown"
3940
import { CommandExecution } from "./CommandExecution"
4041
import { CommandExecutionError } from "./CommandExecutionError"
@@ -252,6 +253,14 @@ export const ChatRowContent = ({
252253
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.title")}</span>
253254
) : apiRequestFailedMessage ? (
254255
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:apiRequest.failed")}</span>
256+
) : isStreaming ? (
257+
<AnimatedStatusIndicator
258+
isStreaming={true}
259+
cost={cost}
260+
cancelReason={apiReqCancelReason}
261+
apiRequestFailedMessage={apiRequestFailedMessage}
262+
streamingFailedMessage={apiReqStreamingFailedMessage}
263+
/>
255264
) : (
256265
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.streaming")}</span>
257266
),
@@ -267,7 +276,18 @@ export const ChatRowContent = ({
267276
default:
268277
return [null, null]
269278
}
270-
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
279+
}, [
280+
type,
281+
isCommandExecuting,
282+
message,
283+
isMcpServerResponding,
284+
apiReqCancelReason,
285+
cost,
286+
apiRequestFailedMessage,
287+
apiReqStreamingFailedMessage,
288+
isStreaming,
289+
t,
290+
])
271291

272292
const headerStyle: React.CSSProperties = {
273293
display: "flex",
@@ -968,6 +988,9 @@ export const ChatRowContent = ({
968988
/>
969989
)
970990
case "api_req_started":
991+
// Check if we should show the animated indicator
992+
const showAnimated = isLast && !cost && !apiReqCancelReason && !apiRequestFailedMessage
993+
971994
return (
972995
<>
973996
<div
@@ -987,8 +1010,23 @@ export const ChatRowContent = ({
9871010
}}
9881011
onClick={handleToggleExpand}>
9891012
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
990-
{icon}
991-
{title}
1013+
{showAnimated ? (
1014+
<>
1015+
<ProgressIndicator />
1016+
<AnimatedStatusIndicator
1017+
isStreaming={true}
1018+
cost={cost}
1019+
cancelReason={apiReqCancelReason}
1020+
apiRequestFailedMessage={apiRequestFailedMessage}
1021+
streamingFailedMessage={apiReqStreamingFailedMessage}
1022+
/>
1023+
</>
1024+
) : (
1025+
<>
1026+
{icon}
1027+
{title}
1028+
</>
1029+
)}
9921030
<VSCodeBadge
9931031
style={{ opacity: cost !== null && cost !== undefined && cost > 0 ? 1 : 0 }}>
9941032
${Number(cost || 0)?.toFixed(4)}

0 commit comments

Comments
 (0)