diff --git a/ui/desktop/src/components/AnimatedIcons.tsx b/ui/desktop/src/components/AnimatedIcons.tsx new file mode 100644 index 000000000000..2a291f76b02f --- /dev/null +++ b/ui/desktop/src/components/AnimatedIcons.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import { + CodeXml, + Cog, + Fuel, + GalleryHorizontalEnd, + Gavel, + GlassWater, + Grape, + Watch0, + Watch1, + Watch2, + Watch3, + Watch4, + Watch5, + Watch6, +} from './icons'; + +interface AnimatedIconsProps { + className?: string; + cycleInterval?: number; // milliseconds between icon changes + variant?: 'thinking' | 'waiting'; +} + +const thinkingIcons = [CodeXml, Cog, Fuel, GalleryHorizontalEnd, Gavel, GlassWater, Grape]; +const waitingIcons = [Watch0, Watch1, Watch2, Watch3, Watch4, Watch5, Watch6]; + +export default function AnimatedIcons({ + className = '', + cycleInterval = 500, + variant = 'thinking', +}: AnimatedIconsProps) { + const [currentIconIndex, setCurrentIconIndex] = useState(0); + const icons = variant === 'thinking' ? thinkingIcons : waitingIcons; + + useEffect(() => { + const interval = setInterval(() => { + setCurrentIconIndex((prevIndex) => (prevIndex + 1) % icons.length); + }, cycleInterval); + + return () => clearInterval(interval); + }, [cycleInterval, icons]); + + const CurrentIcon = icons[currentIconIndex]; + + return ( +
+ +
+ ); +} diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index e51e74744069..9897c0f49953 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -67,6 +67,7 @@ import { useSessionContinuation } from '../hooks/useSessionContinuation'; import { useFileDrop } from '../hooks/useFileDrop'; import { useCostTracking } from '../hooks/useCostTracking'; import { Message } from '../types/message'; +import { ChatState } from '../types/chatState'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); @@ -138,9 +139,7 @@ function BaseChatContent({ ancestorMessages, setAncestorMessages, append, - isLoading, - isWaiting, - isStreaming, + chatState, error, setMessages, input: _input, @@ -226,8 +225,10 @@ function BaseChatContent({ // Handle recipe auto-execution useEffect(() => { - handleAutoExecution(append, isLoading); - }, [handleAutoExecution, append, isLoading]); + const isProcessingResponse = + chatState !== ChatState.Idle && chatState !== ChatState.WaitingForUserInput; + handleAutoExecution(append, isProcessingResponse); + }, [handleAutoExecution, append, chatState]); // Use shared session continuation const { createNewSessionIfNeeded } = useSessionContinuation({ @@ -406,7 +407,7 @@ function BaseChatContent({ }} isUserMessage={isUserMessage} onScrollToBottom={handleScrollToBottom} - isStreamingMessage={isLoading} + isStreamingMessage={chatState !== ChatState.Idle} /> ) : ( // Render messages with SearchView wrapper when search is enabled @@ -422,7 +423,7 @@ function BaseChatContent({ }} isUserMessage={isUserMessage} onScrollToBottom={handleScrollToBottom} - isStreamingMessage={isLoading} + isStreamingMessage={chatState !== ChatState.Idle} /> )} @@ -501,12 +502,11 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {isLoading && ( + {chatState !== ChatState.Idle && (
)} @@ -517,7 +517,7 @@ function BaseChatContent({ > void; - isLoading?: boolean; + chatState: ChatState; onStop?: () => void; commandHistory?: string[]; // Current chat's message history initialValue?: string; @@ -78,7 +79,7 @@ interface ChatInputProps { export default function ChatInput({ handleSubmit, - isLoading = false, + chatState = ChatState.Idle, onStop, commandHistory = [], initialValue = '', @@ -99,6 +100,9 @@ export default function ChatInput({ const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); const [pastedImages, setPastedImages] = useState([]); + + // Derived state - chatState != Idle means we're in some form of loading state + const isLoading = chatState !== ChatState.Idle; const { alerts, addAlert, clearAlerts } = useAlerts(); const dropdownRef = useRef(null); const toolCount = useToolCount(); diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index e84a4efc7030..70e6156979d3 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -1,25 +1,22 @@ import GooseLogo from './GooseLogo'; -import ThinkingIcons from './ThinkingIcons'; +import AnimatedIcons from './AnimatedIcons'; import FlyingBird from './FlyingBird'; +import { ChatState } from '../types/chatState'; interface LoadingGooseProps { message?: string; - isWaiting?: boolean; - isStreaming?: boolean; + chatState?: ChatState; } -const LoadingGoose = ({ - message, - isWaiting = false, - isStreaming = false -}: LoadingGooseProps) => { +const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => { // Determine the appropriate message based on state const getLoadingMessage = () => { if (message) return message; // Custom message takes priority - - if (isWaiting) return 'goose is thinking…'; - if (isStreaming) return 'goose is working on it…'; - + + if (chatState === ChatState.Thinking) return 'goose is thinking…'; + if (chatState === ChatState.Streaming) return 'goose is working on it…'; + if (chatState === ChatState.WaitingForUserInput) return 'goose is waiting…'; + // Default fallback return 'goose is working on it…'; }; @@ -30,10 +27,12 @@ const LoadingGoose = ({ data-testid="loading-indicator" className="flex items-center gap-2 text-xs text-textStandard py-2" > - {isWaiting ? ( - - ) : isStreaming ? ( + {chatState === ChatState.Thinking ? ( + + ) : chatState === ChatState.Streaming ? ( + ) : chatState === ChatState.WaitingForUserInput ? ( + ) : ( )} diff --git a/ui/desktop/src/components/ThinkingIcons.tsx b/ui/desktop/src/components/ThinkingIcons.tsx deleted file mode 100644 index c7affaaa8255..000000000000 --- a/ui/desktop/src/components/ThinkingIcons.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect } from 'react'; -import { CodeXml, Cog, Fuel, GalleryHorizontalEnd, Gavel, GlassWater, Grape } from './icons'; - -interface ThinkingIconsProps { - className?: string; - cycleInterval?: number; // milliseconds between icon changes -} - -const thinkingIcons = [ - CodeXml, - Cog, - Fuel, - GalleryHorizontalEnd, - Gavel, - GlassWater, - Grape, -]; - -export default function ThinkingIcons({ - className = '', - cycleInterval = 500 -}: ThinkingIconsProps) { - const [currentIconIndex, setCurrentIconIndex] = useState(0); - - useEffect(() => { - const interval = setInterval(() => { - setCurrentIconIndex((prevIndex) => - (prevIndex + 1) % thinkingIcons.length - ); - }, cycleInterval); - - return () => clearInterval(interval); - }, [cycleInterval]); - - const CurrentIcon = thinkingIcons[currentIconIndex]; - - return ( -
- -
- ); -} diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 2a3c9954ac3a..5a9a715f302b 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -25,6 +25,7 @@ import { type View, ViewOptions } from '../App'; import { SessionInsights } from './sessions/SessionsInsights'; import ChatInput from './ChatInput'; import { generateSessionId } from '../sessions'; +import { ChatState } from '../types/chatState'; import { ChatContextManagerProvider } from './context_management/ChatContextManager'; import 'react-toastify/dist/ReactToastify.css'; @@ -87,7 +88,7 @@ export default function Hub({ {}} commandHistory={[]} initialValue="" diff --git a/ui/desktop/src/components/icons/Watch0.tsx b/ui/desktop/src/components/icons/Watch0.tsx new file mode 100644 index 000000000000..286ad8218ebc --- /dev/null +++ b/ui/desktop/src/components/icons/Watch0.tsx @@ -0,0 +1,20 @@ +export function Watch0({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch1.tsx b/ui/desktop/src/components/icons/Watch1.tsx new file mode 100644 index 000000000000..c5a78702f9db --- /dev/null +++ b/ui/desktop/src/components/icons/Watch1.tsx @@ -0,0 +1,20 @@ +export function Watch1({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch2.tsx b/ui/desktop/src/components/icons/Watch2.tsx new file mode 100644 index 000000000000..ed0e50727f78 --- /dev/null +++ b/ui/desktop/src/components/icons/Watch2.tsx @@ -0,0 +1,20 @@ +export function Watch2({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch3.tsx b/ui/desktop/src/components/icons/Watch3.tsx new file mode 100644 index 000000000000..55621670d75b --- /dev/null +++ b/ui/desktop/src/components/icons/Watch3.tsx @@ -0,0 +1,20 @@ +export function Watch3({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch4.tsx b/ui/desktop/src/components/icons/Watch4.tsx new file mode 100644 index 000000000000..7f39859d34ba --- /dev/null +++ b/ui/desktop/src/components/icons/Watch4.tsx @@ -0,0 +1,20 @@ +export function Watch4({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch5.tsx b/ui/desktop/src/components/icons/Watch5.tsx new file mode 100644 index 000000000000..a15f9e180377 --- /dev/null +++ b/ui/desktop/src/components/icons/Watch5.tsx @@ -0,0 +1,20 @@ +export function Watch5({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Watch6.tsx b/ui/desktop/src/components/icons/Watch6.tsx new file mode 100644 index 000000000000..6a526d749fc7 --- /dev/null +++ b/ui/desktop/src/components/icons/Watch6.tsx @@ -0,0 +1,20 @@ +export function Watch6({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index 2760c2dc8692..7e76ab55b938 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -38,6 +38,13 @@ import Time from './Time'; import { Gear } from './Gear'; import Youtube from './Youtube'; import { Microphone } from './Microphone'; +import { Watch0 } from './Watch0'; +import { Watch1 } from './Watch1'; +import { Watch2 } from './Watch2'; +import { Watch3 } from './Watch3'; +import { Watch4 } from './Watch4'; +import { Watch5 } from './Watch5'; +import { Watch6 } from './Watch6'; export { ArrowDown, @@ -79,5 +86,12 @@ export { Send, Settings, Time, + Watch0, + Watch1, + Watch2, + Watch3, + Watch4, + Watch5, + Watch6, Youtube, }; diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index c183688f833d..512bffd091c4 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -67,9 +67,7 @@ export const useChatEngine = ({ messages, append: originalAppend, stop, - isLoading, - isWaiting, - isStreaming, + chatState, error, setMessages, input: _input, @@ -371,9 +369,7 @@ export const useChatEngine = ({ // Message stream controls append, stop, - isLoading, - isWaiting, - isStreaming, + chatState, error, setMessages, diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 4850e8b1a2c0..31ad90e7c9ce 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -3,6 +3,7 @@ import useSWR from 'swr'; import { getSecretKey } from '../config'; import { Message, createUserMessage, hasCompletedToolCalls } from '../types/message'; import { getSessionHistory } from '../api'; +import { ChatState } from '../types/chatState'; let messageIdCounter = 0; @@ -151,14 +152,8 @@ export interface UseMessageStreamHelpers { /** Form submission handler to automatically reset input and append a user message */ handleSubmit: (event?: { preventDefault?: () => void }) => void; - /** Whether the API request is in progress */ - isLoading: boolean; - - /** Whether we're waiting for the first response from LLM */ - isWaiting: boolean; - - /** Whether we're actively streaming response content */ - isStreaming: boolean; + /** Current chat state (idle, thinking, streaming, waiting for user input) */ + chatState: ChatState; /** Add a tool result to a tool call */ addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; @@ -223,20 +218,9 @@ export function useMessageStream({ messagesRef.current = messages || []; }, [messages]); - // We store loading state in another hook to sync loading states across hook invocations - const { data: isLoading = false, mutate: mutateLoading } = useSWR( - [chatKey, 'loading'], - null - ); - - // Track waiting vs streaming states - const { data: isWaiting = false, mutate: mutateWaiting } = useSWR( - [chatKey, 'waiting'], - null - ); - - const { data: isStreaming = false, mutate: mutateStreaming } = useSWR( - [chatKey, 'streaming'], + // Track chat state (idle, thinking, streaming, waiting for user input) + const { data: chatState = ChatState.Idle, mutate: mutateChatState } = useSWR( + [chatKey, 'chatState'], null ); @@ -300,8 +284,7 @@ export function useMessageStream({ switch (parsedEvent.type) { case 'Message': { // Transition from waiting to streaming on first message - mutateWaiting(false); - mutateStreaming(true); + mutateChatState(ChatState.Streaming); // Create a new message object with the properties preserved or defaulted const newMessage = { @@ -333,6 +316,15 @@ export function useMessageStream({ currentMessages = [...currentMessages, newMessage]; } + // Check if this message contains tool confirmation requests + const hasToolConfirmation = newMessage.content.some( + (content) => content.type === 'toolConfirmationRequest' + ); + + if (hasToolConfirmation) { + mutateChatState(ChatState.WaitingForUserInput); + } + mutate(currentMessages, false); break; } @@ -464,16 +456,14 @@ export function useMessageStream({ return currentMessages; }, - [mutate, mutateWaiting, mutateStreaming, onFinish, onError, forceUpdate, setError] + [mutate, mutateChatState, onFinish, onError, forceUpdate, setError] ); // Send a request to the server const sendRequest = useCallback( async (requestMessages: Message[]) => { try { - mutateLoading(true); - mutateWaiting(true); // Start in waiting state - mutateStreaming(false); + mutateChatState(ChatState.Thinking); // Start in thinking state setError(undefined); // Create abort controller @@ -544,23 +534,22 @@ export function useMessageStream({ setError(err as Error); } finally { - mutateLoading(false); - mutateWaiting(false); - mutateStreaming(false); + // Check if the last message has pending tool confirmations + const currentMessages = messagesRef.current; + const lastMessage = currentMessages[currentMessages.length - 1]; + const hasPendingToolConfirmation = lastMessage?.content.some( + (content) => content.type === 'toolConfirmationRequest' + ); + + if (hasPendingToolConfirmation) { + mutateChatState(ChatState.WaitingForUserInput); + } else { + mutateChatState(ChatState.Idle); + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ - api, - processMessageStream, - mutateLoading, - mutateWaiting, - mutateStreaming, - setError, - onResponse, - onError, - maxSteps, - ] + [api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps] ); // Append a new message and send request @@ -569,11 +558,16 @@ export function useMessageStream({ // If a string is passed, convert it to a Message object const messageToAppend = typeof message === 'string' ? createUserMessage(message) : message; + // If we were waiting for user input and user provides input, transition away from that state + if (chatState === ChatState.WaitingForUserInput) { + mutateChatState(ChatState.Thinking); + } + const currentMessages = [...messagesRef.current, messageToAppend]; mutate(currentMessages, false); await sendRequest(currentMessages); }, - [mutate, sendRequest] + [mutate, sendRequest, chatState, mutateChatState] ); // Reload the last message @@ -704,9 +698,7 @@ export function useMessageStream({ setInput, handleInputChange, handleSubmit, - isLoading: isLoading || false, - isWaiting: isWaiting || false, - isStreaming: isStreaming || false, + chatState, addToolResult, updateMessageStreamBody, notifications, diff --git a/ui/desktop/src/types/chatState.ts b/ui/desktop/src/types/chatState.ts new file mode 100644 index 000000000000..4adc5f0ece57 --- /dev/null +++ b/ui/desktop/src/types/chatState.ts @@ -0,0 +1,6 @@ +export enum ChatState { + Idle = 'idle', + Thinking = 'thinking', + Streaming = 'streaming', + WaitingForUserInput = 'waitingForUserInput', +}