diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 971f05dbb766..6814ace4eb2a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2332,17 +2332,16 @@ } }, "ResourceContents": { - "oneOf": [ + "anyOf": [ { "type": "object", "required": [ - "uri", - "text" + "text", + "uri" ], "properties": { "mime_type": { - "type": "string", - "nullable": true + "type": "string" }, "text": { "type": "string" @@ -2355,16 +2354,15 @@ { "type": "object", "required": [ - "uri", - "blob" + "blob", + "uri" ], "properties": { "blob": { "type": "string" }, "mime_type": { - "type": "string", - "nullable": true + "type": "string" }, "uri": { "type": "string" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 53230d066b87..6df01c7809fc 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -463,12 +463,12 @@ export type RedactedThinkingContent = { }; export type ResourceContents = { - mime_type?: string | null; + mime_type?: string; text: string; uri: string; } | { blob: string; - mime_type?: string | null; + mime_type?: string; uri: string; }; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 247d8bee1116..5b35122805db 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -145,6 +145,8 @@ function BaseChatContent({ setAncestorMessages, append, isLoading, + isWaiting, + isStreaming, error, setMessages, input: _input, @@ -482,7 +484,11 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} {isLoading && (
- +
)} diff --git a/ui/desktop/src/components/FlyingBird.tsx b/ui/desktop/src/components/FlyingBird.tsx new file mode 100644 index 000000000000..93baa3f5be6c --- /dev/null +++ b/ui/desktop/src/components/FlyingBird.tsx @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import { Bird1, Bird2, Bird3, Bird4, Bird5, Bird6 } from './icons'; + +interface FlyingBirdProps { + className?: string; + cycleInterval?: number; // milliseconds between bird frame changes +} + +const birdFrames = [ + Bird1, + Bird2, + Bird3, + Bird4, + Bird5, + Bird6, +]; + +export default function FlyingBird({ + className = '', + cycleInterval = 150 +}: FlyingBirdProps) { + const [currentFrameIndex, setCurrentFrameIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setCurrentFrameIndex((prevIndex) => + (prevIndex + 1) % birdFrames.length + ); + }, cycleInterval); + + return () => clearInterval(interval); + }, [cycleInterval]); + + const CurrentFrame = birdFrames[currentFrameIndex]; + + return ( +
+ +
+ ); +} diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index a5f6fd80019e..e84a4efc7030 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -1,18 +1,43 @@ import GooseLogo from './GooseLogo'; +import ThinkingIcons from './ThinkingIcons'; +import FlyingBird from './FlyingBird'; interface LoadingGooseProps { message?: string; + isWaiting?: boolean; + isStreaming?: boolean; } -const LoadingGoose = ({ message = 'goose is working on it…' }: LoadingGooseProps) => { +const LoadingGoose = ({ + message, + isWaiting = false, + isStreaming = false +}: 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…'; + + // Default fallback + return 'goose is working on it…'; + }; + return (
- - {message} + {isWaiting ? ( + + ) : isStreaming ? ( + + ) : ( + + )} + {getLoadingMessage()}
); diff --git a/ui/desktop/src/components/ThinkingIcons.tsx b/ui/desktop/src/components/ThinkingIcons.tsx new file mode 100644 index 000000000000..c7affaaa8255 --- /dev/null +++ b/ui/desktop/src/components/ThinkingIcons.tsx @@ -0,0 +1,42 @@ +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/icons/Bird1.tsx b/ui/desktop/src/components/icons/Bird1.tsx new file mode 100644 index 000000000000..bcb7b433f456 --- /dev/null +++ b/ui/desktop/src/components/icons/Bird1.tsx @@ -0,0 +1,31 @@ +export function Bird1({ className = '' }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Bird2.tsx b/ui/desktop/src/components/icons/Bird2.tsx new file mode 100644 index 000000000000..130d18f5c0eb --- /dev/null +++ b/ui/desktop/src/components/icons/Bird2.tsx @@ -0,0 +1,29 @@ +export function Bird2({ className = '' }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Bird3.tsx b/ui/desktop/src/components/icons/Bird3.tsx new file mode 100644 index 000000000000..6642e575c1f2 --- /dev/null +++ b/ui/desktop/src/components/icons/Bird3.tsx @@ -0,0 +1,31 @@ +export function Bird3({ className = '' }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Bird4.tsx b/ui/desktop/src/components/icons/Bird4.tsx new file mode 100644 index 000000000000..cf8b8f9a8d66 --- /dev/null +++ b/ui/desktop/src/components/icons/Bird4.tsx @@ -0,0 +1,31 @@ +export function Bird4({ className = '' }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Bird5.tsx b/ui/desktop/src/components/icons/Bird5.tsx new file mode 100644 index 000000000000..ecdadabcb10f --- /dev/null +++ b/ui/desktop/src/components/icons/Bird5.tsx @@ -0,0 +1,25 @@ +export function Bird5({ className = '' }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Bird6.tsx b/ui/desktop/src/components/icons/Bird6.tsx new file mode 100644 index 000000000000..db1471509c16 --- /dev/null +++ b/ui/desktop/src/components/icons/Bird6.tsx @@ -0,0 +1,31 @@ +export function Bird6({ className = '' }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/CodeXml.tsx b/ui/desktop/src/components/icons/CodeXml.tsx new file mode 100644 index 000000000000..470a90ee6e17 --- /dev/null +++ b/ui/desktop/src/components/icons/CodeXml.tsx @@ -0,0 +1,26 @@ +export function CodeXml({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Cog.tsx b/ui/desktop/src/components/icons/Cog.tsx new file mode 100644 index 000000000000..9e91ae42c4d6 --- /dev/null +++ b/ui/desktop/src/components/icons/Cog.tsx @@ -0,0 +1,26 @@ +export function Cog({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Fuel.tsx b/ui/desktop/src/components/icons/Fuel.tsx new file mode 100644 index 000000000000..ff8b380ffbad --- /dev/null +++ b/ui/desktop/src/components/icons/Fuel.tsx @@ -0,0 +1,26 @@ +export function Fuel({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/GalleryHorizontalEnd.tsx b/ui/desktop/src/components/icons/GalleryHorizontalEnd.tsx new file mode 100644 index 000000000000..4f036b3866eb --- /dev/null +++ b/ui/desktop/src/components/icons/GalleryHorizontalEnd.tsx @@ -0,0 +1,26 @@ +export function GalleryHorizontalEnd({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/Gavel.tsx b/ui/desktop/src/components/icons/Gavel.tsx new file mode 100644 index 000000000000..799095576513 --- /dev/null +++ b/ui/desktop/src/components/icons/Gavel.tsx @@ -0,0 +1,26 @@ +export function Gavel({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/GlassWater.tsx b/ui/desktop/src/components/icons/GlassWater.tsx new file mode 100644 index 000000000000..5edef50dec77 --- /dev/null +++ b/ui/desktop/src/components/icons/GlassWater.tsx @@ -0,0 +1,19 @@ +export function GlassWater({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Grape.tsx b/ui/desktop/src/components/icons/Grape.tsx new file mode 100644 index 000000000000..4e3c21c38a93 --- /dev/null +++ b/ui/desktop/src/components/icons/Grape.tsx @@ -0,0 +1,26 @@ +export function Grape({ className = '' }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index dab2ee5428b9..2760c2dc8692 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -2,17 +2,30 @@ import ArrowDown from './ArrowDown'; import ArrowUp from './ArrowUp'; import Attach from './Attach'; import Back from './Back'; +import { Bird1 } from './Bird1'; +import { Bird2 } from './Bird2'; +import { Bird3 } from './Bird3'; +import { Bird4 } from './Bird4'; +import { Bird5 } from './Bird5'; +import { Bird6 } from './Bird6'; import ChatSmart from './ChatSmart'; import Check from './Check'; import ChevronDown from './ChevronDown'; import ChevronUp from './ChevronUp'; import { ChevronRight } from './ChevronRight'; import Close from './Close'; +import { CodeXml } from './CodeXml'; +import { Cog } from './Cog'; import CoinIcon from './CoinIcon'; import Copy from './Copy'; import Discord from './Discord'; import Document from './Document'; import Edit from './Edit'; +import { Fuel } from './Fuel'; +import { GalleryHorizontalEnd } from './GalleryHorizontalEnd'; +import { Gavel } from './Gavel'; +import { GlassWater } from './GlassWater'; +import { Grape } from './Grape'; import Idea from './Idea'; import LinkedIn from './LinkedIn'; import More from './More'; @@ -31,21 +44,34 @@ export { ArrowUp, Attach, Back, + Bird1, + Bird2, + Bird3, + Bird4, + Bird5, + Bird6, ChatSmart, Check, ChevronDown, ChevronRight, ChevronUp, Close, + CodeXml, + Cog, CoinIcon, Copy, Discord, Document, Edit, - Idea, + Fuel, + GalleryHorizontalEnd, + Gavel, Gear, - Microphone, + GlassWater, + Grape, + Idea, LinkedIn, + Microphone, More, Refresh, SensitiveHidden, diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index 4f2613abe78a..6706e523d065 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -68,6 +68,8 @@ export const useChatEngine = ({ append: originalAppend, stop, isLoading, + isWaiting, + isStreaming, error, setMessages, input: _input, @@ -369,6 +371,8 @@ export const useChatEngine = ({ append, stop, isLoading, + isWaiting, + isStreaming, error, setMessages, diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index e4db0ac4372e..50807cf54c01 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -148,6 +148,12 @@ export interface UseMessageStreamHelpers { /** 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; + /** Add a tool result to a tool call */ addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; @@ -214,6 +220,17 @@ export function useMessageStream({ 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'], + null + ); + const { data: error = undefined, mutate: setError } = useSWR( [chatKey, 'error'], null @@ -273,6 +290,10 @@ export function useMessageStream({ switch (parsedEvent.type) { case 'Message': { + // Transition from waiting to streaming on first message + mutateWaiting(false); + mutateStreaming(true); + // Create a new message object with the properties preserved or defaulted const newMessage = { ...parsedEvent.message, @@ -432,7 +453,7 @@ export function useMessageStream({ return currentMessages; }, - [mutate, onFinish, onError, forceUpdate, setError] + [mutate, mutateWaiting, mutateStreaming, onFinish, onError, forceUpdate, setError] ); // Send a request to the server @@ -440,6 +461,8 @@ export function useMessageStream({ async (requestMessages: Message[]) => { try { mutateLoading(true); + mutateWaiting(true); // Start in waiting state + mutateStreaming(false); setError(undefined); // Create abort controller @@ -511,10 +534,12 @@ export function useMessageStream({ setError(err as Error); } finally { mutateLoading(false); + mutateWaiting(false); + mutateStreaming(false); } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [api, processMessageStream, mutateLoading, setError, onResponse, onError, maxSteps] + [api, processMessageStream, mutateLoading, mutateWaiting, mutateStreaming, setError, onResponse, onError, maxSteps] ); // Append a new message and send request @@ -658,6 +683,8 @@ export function useMessageStream({ handleInputChange, handleSubmit, isLoading: isLoading || false, + isWaiting: isWaiting || false, + isStreaming: isStreaming || false, addToolResult, updateMessageStreamBody, notifications,