diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e2df805340..d771ece4aa 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -212,6 +212,7 @@ export interface WebviewMessage { | "createCommand" | "insertTextIntoTextarea" | "showMdmAuthRequiredNotification" + | "apiStatusConfig" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" diff --git a/webview-ui/src/components/chat/AnimatedStatusIndicator.tsx b/webview-ui/src/components/chat/AnimatedStatusIndicator.tsx new file mode 100644 index 0000000000..6b17d6ccc6 --- /dev/null +++ b/webview-ui/src/components/chat/AnimatedStatusIndicator.tsx @@ -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 = ({ + 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 ( +
+ {config.emojisEnabled && ( + {config.emojis[currentEmojiIndex]} + )} + + {statusText} + +
+ ) +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ad33ae9187..4b10ec66be 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 = ({ {t("chat:apiRequest.title")} ) : apiRequestFailedMessage ? ( {t("chat:apiRequest.failed")} + ) : isStreaming ? ( + ) : ( {t("chat:apiRequest.streaming")} ), @@ -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 ( <>
- {icon} - {title} + {showAnimated ? ( + <> + + + + ) : ( + <> + {icon} + {title} + + )} 0 ? 1 : 0 }}> ${Number(cost || 0)?.toFixed(4)} diff --git a/webview-ui/src/components/settings/AnimatedStatusSettings.tsx b/webview-ui/src/components/settings/AnimatedStatusSettings.tsx new file mode 100644 index 0000000000..4d005ea463 --- /dev/null +++ b/webview-ui/src/components/settings/AnimatedStatusSettings.tsx @@ -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 = ({ + 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 ( +
+
+ setLocalConfig((prev) => ({ ...prev, enabled: e.target.checked }))}> + Enable animated status indicator + +
+ + {localConfig.enabled && ( + <> +
+ {/* Random Mode */} +
+ + setLocalConfig((prev) => ({ ...prev, randomMode: e.target.checked })) + }> + Cycle through status messages + +
+ + {localConfig.randomMode && ( +
+ + { + const seconds = parseFloat(e.target.value) || 5 + setLocalConfig((prev) => ({ + ...prev, + cycleInterval: seconds * 1000, + })) + }} + style={{ width: "100px" }} + /> +
+ )} + + {/* Custom Status Texts */} +
+ +
+ {localConfig.customTexts.length === 0 + ? `Using default messages: ${DEFAULT_STATUS_TEXTS.join(", ")}` + : "Custom messages:"} +
+ + {localConfig.customTexts.map((text, index) => ( +
+ {text} + removeStatusText(index)}> + + +
+ ))} + +
+ setNewStatusText(e.target.value)} + placeholder="Add custom status message..." + onKeyDown={(e: any) => { + if (e.key === "Enter") { + addStatusText() + } + }} + style={{ flex: 1 }} + /> + + + +
+
+ + {/* Emoji Mode */} +
+ + setLocalConfig((prev) => ({ ...prev, emojisEnabled: e.target.checked })) + }> + Show emoji with status + +
+ + {localConfig.emojisEnabled && ( +
+ +
+ {localConfig.customEmojis.length === 0 + ? `Using default emojis: ${DEFAULT_EMOJIS.join(" ")}` + : "Custom emojis:"} +
+ +
+ {localConfig.customEmojis.map((emoji, index) => ( +
+ {emoji} + removeEmoji(index)} + style={{ minWidth: "20px", height: "20px", padding: "2px" }}> + + +
+ ))} +
+ +
+ setNewEmoji(e.target.value)} + placeholder="Add emoji..." + maxlength={2} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + addEmoji() + } + }} + style={{ width: "100px" }} + /> + + + +
+
+ )} +
+ + )} +
+ ) +} diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 4d0bb8aba6..2117ff85dd 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -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 & { 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 = ({
+ {/* Add Animated Status Settings at the top */} + {setApiStatusConfig && ( +
+ +
+ )} + {Object.entries(experimentConfigsMap) .filter(([key]) => key in EXPERIMENT_IDS) .map((config) => { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 682387ca2f..60720e5505 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -343,6 +343,7 @@ const SettingsView = forwardRef(({ 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(({ onDone, t experiments={experiments} apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} + apiStatusConfig={cachedState.apiStatusConfig} + setApiStatusConfig={(value) => setCachedStateField("apiStatusConfig", value)} /> )} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index bd335d7b2d..5ea86bdfbb 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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, diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index ba7aeb576e..e3ac5f0492 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -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;