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
This commit is contained in:
Roo Code 2025-08-29 10:45:57 +00:00
parent 1d46bd1bbc
commit 4ed2a3d514
8 changed files with 435 additions and 3 deletions

View file

@ -212,6 +212,7 @@ export interface WebviewMessage {
| "createCommand"
| "insertTextIntoTextarea"
| "showMdmAuthRequiredNotification"
| "apiStatusConfig"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"

View file

@ -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
}
const DEFAULT_STATUS_TEXTS = ["Generating...", "Thinking...", "Working on it...", "Processing...", "Analyzing..."]
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">
{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>
)
}

View file

@ -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"
@ -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>
),
@ -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",
@ -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
@ -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)}

View file

@ -0,0 +1,221 @@
import React, { useState, useEffect } from "react"
import { VSCodeButton, VSCodeTextField, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Plus, Trash2 } from "lucide-react"
interface AnimatedStatusSettingsProps {
apiStatusConfig?: {
enabled?: boolean
customTexts?: string[]
emojisEnabled?: boolean
customEmojis?: string[]
randomMode?: boolean
cycleInterval?: number
}
setApiStatusConfig: (value: any) => void
}
const DEFAULT_STATUS_TEXTS = ["Generating...", "Thinking...", "Working on it...", "Processing...", "Analyzing..."]
const DEFAULT_EMOJIS = [
"🤔", // Thinking
"🧠", // Brainstorming
"⏳", // Loading
"✨", // Magic
"🔮", // Summoning
"💭", // Thought bubble
"⚡", // Lightning
"🎯", // Target
]
export const AnimatedStatusSettings: React.FC<AnimatedStatusSettingsProps> = ({
apiStatusConfig = {},
setApiStatusConfig,
}) => {
const [localConfig, setLocalConfig] = useState({
enabled: apiStatusConfig.enabled !== false,
customTexts: apiStatusConfig.customTexts || [],
emojisEnabled: apiStatusConfig.emojisEnabled === true,
customEmojis: apiStatusConfig.customEmojis || [],
randomMode: apiStatusConfig.randomMode !== false,
cycleInterval: apiStatusConfig.cycleInterval || 5000,
})
const [newStatusText, setNewStatusText] = useState("")
const [newEmoji, setNewEmoji] = useState("")
useEffect(() => {
setApiStatusConfig(localConfig)
}, [localConfig, setApiStatusConfig])
const addStatusText = () => {
if (newStatusText.trim()) {
setLocalConfig((prev) => ({
...prev,
customTexts: [...prev.customTexts, newStatusText.trim()],
}))
setNewStatusText("")
}
}
const removeStatusText = (index: number) => {
setLocalConfig((prev) => ({
...prev,
customTexts: prev.customTexts.filter((_, i) => i !== index),
}))
}
const addEmoji = () => {
if (newEmoji.trim()) {
setLocalConfig((prev) => ({
...prev,
customEmojis: [...prev.customEmojis, newEmoji.trim()],
}))
setNewEmoji("")
}
}
const removeEmoji = (index: number) => {
setLocalConfig((prev) => ({
...prev,
customEmojis: prev.customEmojis.filter((_, i) => i !== index),
}))
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={localConfig.enabled}
onChange={(e: any) => setLocalConfig((prev) => ({ ...prev, enabled: e.target.checked }))}>
Enable animated status indicator
</VSCodeCheckbox>
</div>
{localConfig.enabled && (
<>
<div className="ml-6 space-y-4">
{/* Random Mode */}
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={localConfig.randomMode}
onChange={(e: any) =>
setLocalConfig((prev) => ({ ...prev, randomMode: e.target.checked }))
}>
Cycle through status messages
</VSCodeCheckbox>
</div>
{localConfig.randomMode && (
<div className="ml-6">
<label className="block text-sm mb-1">Cycle interval (seconds)</label>
<VSCodeTextField
value={String(localConfig.cycleInterval / 1000)}
onChange={(e: any) => {
const seconds = parseFloat(e.target.value) || 5
setLocalConfig((prev) => ({
...prev,
cycleInterval: seconds * 1000,
}))
}}
style={{ width: "100px" }}
/>
</div>
)}
{/* Custom Status Texts */}
<div>
<label className="block text-sm font-medium mb-2">Status Messages</label>
<div className="text-xs text-vscode-descriptionForeground mb-2">
{localConfig.customTexts.length === 0
? `Using default messages: ${DEFAULT_STATUS_TEXTS.join(", ")}`
: "Custom messages:"}
</div>
{localConfig.customTexts.map((text, index) => (
<div key={index} className="flex items-center gap-2 mb-2">
<span className="flex-1">{text}</span>
<VSCodeButton appearance="icon" onClick={() => removeStatusText(index)}>
<Trash2 className="w-4 h-4" />
</VSCodeButton>
</div>
))}
<div className="flex items-center gap-2">
<VSCodeTextField
value={newStatusText}
onChange={(e: any) => setNewStatusText(e.target.value)}
placeholder="Add custom status message..."
onKeyDown={(e: any) => {
if (e.key === "Enter") {
addStatusText()
}
}}
style={{ flex: 1 }}
/>
<VSCodeButton appearance="icon" onClick={addStatusText}>
<Plus className="w-4 h-4" />
</VSCodeButton>
</div>
</div>
{/* Emoji Mode */}
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={localConfig.emojisEnabled}
onChange={(e: any) =>
setLocalConfig((prev) => ({ ...prev, emojisEnabled: e.target.checked }))
}>
Show emoji with status
</VSCodeCheckbox>
</div>
{localConfig.emojisEnabled && (
<div className="ml-6">
<label className="block text-sm font-medium mb-2">Emojis</label>
<div className="text-xs text-vscode-descriptionForeground mb-2">
{localConfig.customEmojis.length === 0
? `Using default emojis: ${DEFAULT_EMOJIS.join(" ")}`
: "Custom emojis:"}
</div>
<div className="flex flex-wrap gap-2 mb-2">
{localConfig.customEmojis.map((emoji, index) => (
<div
key={index}
className="flex items-center gap-1 px-2 py-1 bg-vscode-badge-background rounded">
<span className="text-lg">{emoji}</span>
<VSCodeButton
appearance="icon"
onClick={() => removeEmoji(index)}
style={{ minWidth: "20px", height: "20px", padding: "2px" }}>
<Trash2 className="w-3 h-3" />
</VSCodeButton>
</div>
))}
</div>
<div className="flex items-center gap-2">
<VSCodeTextField
value={newEmoji}
onChange={(e: any) => setNewEmoji(e.target.value)}
placeholder="Add emoji..."
maxlength={2}
onKeyDown={(e: any) => {
if (e.key === "Enter") {
addEmoji()
}
}}
style={{ width: "100px" }}
/>
<VSCodeButton appearance="icon" onClick={addEmoji}>
<Plus className="w-4 h-4" />
</VSCodeButton>
</div>
</div>
)}
</div>
</>
)}
</div>
)
}

View file

@ -13,12 +13,15 @@ import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { ExperimentalFeature } from "./ExperimentalFeature"
import { ImageGenerationSettings } from "./ImageGenerationSettings"
import { AnimatedStatusSettings } from "./AnimatedStatusSettings"
type ExperimentalSettingsProps = HTMLAttributes<HTMLDivElement> & {
experiments: Experiments
setExperimentEnabled: SetExperimentEnabled
apiConfiguration?: any
setApiConfigurationField?: any
apiStatusConfig?: any
setApiStatusConfig?: (value: any) => void
}
export const ExperimentalSettings = ({
@ -26,6 +29,8 @@ export const ExperimentalSettings = ({
setExperimentEnabled,
apiConfiguration,
setApiConfigurationField,
apiStatusConfig,
setApiStatusConfig,
className,
...props
}: ExperimentalSettingsProps) => {
@ -41,6 +46,16 @@ export const ExperimentalSettings = ({
</SectionHeader>
<Section>
{/* Add Animated Status Settings at the top */}
{setApiStatusConfig && (
<div className="mb-4 pb-4 border-b border-vscode-panel-border">
<AnimatedStatusSettings
apiStatusConfig={apiStatusConfig}
setApiStatusConfig={setApiStatusConfig}
/>
</div>
)}
{Object.entries(experimentConfigsMap)
.filter(([key]) => key in EXPERIMENT_IDS)
.map((config) => {

View file

@ -343,6 +343,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
vscode.postMessage({ type: "apiStatusConfig", values: cachedState.apiStatusConfig || {} })
setChangeDetected(false)
}
}
@ -723,6 +724,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
experiments={experiments}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
apiStatusConfig={cachedState.apiStatusConfig}
setApiStatusConfig={(value) => setCachedStateField("apiStatusConfig", value)}
/>
)}

View file

@ -48,6 +48,15 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowFollowupQuestions: (value: boolean) => void // Setter for the new property
followupAutoApproveTimeoutMs: number | undefined // Timeout in ms for auto-approving follow-up questions
setFollowupAutoApproveTimeoutMs: (value: number) => void // Setter for the timeout
apiStatusConfig?: {
enabled?: boolean
customTexts?: string[]
emojisEnabled?: boolean
customEmojis?: string[]
randomMode?: boolean
cycleInterval?: number
}
setApiStatusConfig: (value: any) => void
condensingApiConfigId?: string
setCondensingApiConfigId: (value: string) => void
customCondensingPrompt?: string
@ -271,6 +280,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
global: {},
})
const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true)
const [apiStatusConfig, setApiStatusConfig] = useState<{
enabled?: boolean
customTexts?: string[]
emojisEnabled?: boolean
customEmojis?: string[]
randomMode?: boolean
cycleInterval?: number
}>({})
const setListApiConfigMeta = useCallback(
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
@ -308,6 +325,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
if ((newState as any).includeTaskHistoryInEnhance !== undefined) {
setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance)
}
// Update apiStatusConfig if present in state message
if ((newState as any).apiStatusConfig !== undefined) {
setApiStatusConfig((newState as any).apiStatusConfig)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
@ -411,6 +432,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
marketplaceItems,
marketplaceInstalledMetadata,
profileThresholds: state.profileThresholds ?? {},
apiStatusConfig,
setApiStatusConfig,
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
remoteControlEnabled: state.remoteControlEnabled ?? false,

View file

@ -475,6 +475,20 @@ input[cmdk-input]:focus {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse-subtle {
0%,
100% {
opacity: 0.9;
}
50% {
opacity: 0.3;
}
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Transition utilities */
.transition-all {
transition-property: all;