diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 4c64e5c83cd2..24d585b430b6 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -52,6 +52,7 @@ module.exports = [ KeyboardEvent: 'readonly', React: 'readonly', handleAction: 'readonly', + requestAnimationFrame: 'readonly', }, }, plugins: { diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index fe45411b954d..39abdfb4ae02 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -35,6 +35,8 @@ export interface Chat { }>; } +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + function ChatContent({ chats, setChats, @@ -57,6 +59,16 @@ function ChatContent({ const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); + const messagesEndRef = useRef(null); + const [working, setWorkingLocal] = useState(Working.Idle); + + useEffect(() => { + setWorking(working); + }, [working, setWorking]); + + const updateWorking = (newWorking: Working) => { + setWorkingLocal(newWorking); + }; const { messages, @@ -69,26 +81,30 @@ function ChatContent({ api: getApiUrl('/reply'), initialMessages: chat?.messages || [], onToolCall: ({ toolCall }) => { - setWorking(Working.Working); + updateWorking(Working.Working); setProgressMessage(`Executing tool: ${toolCall.toolName}`); + requestAnimationFrame(() => scrollToBottom('instant')); }, onResponse: (response) => { if (!response.ok) { setProgressMessage('An error occurred while receiving the response.'); - setWorking(Working.Idle); + updateWorking(Working.Idle); } else { setProgressMessage('thinking...'); - setWorking(Working.Working); + updateWorking(Working.Working); } }, onFinish: async (message, options) => { - setProgressMessage('Task finished. Click here to expand.'); - setWorking(Working.Idle); + setTimeout(() => { + setProgressMessage('Task finished. Click here to expand.'); + updateWorking(Working.Idle); + }, 500); const fetchResponses = await askAi(message.content); setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses })); - // Only show notification if it's been more than a minute since last interaction + requestAnimationFrame(() => scrollToBottom('smooth')); + const timeSinceLastInteraction = Date.now() - lastInteractionTime; window.electron.logInfo("last interaction:" + lastInteractionTime); if (timeSinceLastInteraction > 60000) { // 60000ms = 1 minute @@ -120,15 +136,41 @@ function ChatContent({ } }, [messages]); + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ + behavior, + block: 'end', + inline: 'nearest' + }); + } + }; + + // Single effect to handle all scrolling + useEffect(() => { + if (isLoading || messages.length > 0 || working === Working.Working) { + // Initial scroll + scrollToBottom(isLoading || working === Working.Working ? 'instant' : 'smooth'); + + // // Additional scrolls to catch dynamic content + // [100, 300, 500].forEach(delay => { + // setTimeout(() => scrollToBottom('smooth'), delay); + // }); + } + }, [messages, isLoading, working]); + + // Handle submit const handleSubmit = (e: React.FormEvent) => { const customEvent = e as CustomEvent; const content = customEvent.detail?.value || ''; if (content.trim()) { - setLastInteractionTime(Date.now()); // Update last interaction time + setLastInteractionTime(Date.now()); append({ role: 'user', content: content, }); + // Immediate scroll on submit + scrollToBottom('instant'); } }; @@ -198,13 +240,12 @@ function ChatContent({ {messages.length === 0 ? ( ) : ( - +
-
{ - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'end' }); - } - }}> +
{messages.map((message) => (
{message.role === 'user' ? ( @@ -219,37 +260,38 @@ function ChatContent({ )}
))} -
- {isLoading && ( -
-
setShowGame(true)} style={{ cursor: 'pointer' }}> - -
-
- )} - {error && ( -
-
- {error.message || 'Honk! Goose experienced an error while responding'} - {error.status && ( - (Status: {error.status}) - )} + {isLoading && ( +
+
setShowGame(true)} style={{ cursor: 'pointer' }}> + +
-
{ - const lastUserMessage = messages.reduceRight((found, m) => found || (m.role === 'user' ? m : null), null); - if (lastUserMessage) { - append({ - role: 'user', - content: lastUserMessage.content - }); - } - }}> - Retry Last Message + )} + {error && ( +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} + {error.status && ( + (Status: {error.status}) + )} +
+
{ + const lastUserMessage = messages.reduceRight((found, m) => found || (m.role === 'user' ? m : null), null); + if (lastUserMessage) { + append({ + role: 'user', + content: lastUserMessage.content + }); + } + }}> + Retry Last Message +
-
- )} + )} +
+
)} diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index c5739415a35e..dd6e8922e722 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -7,7 +7,7 @@ import svg5 from '../images/loading-goose/5.svg'; import svg6 from '../images/loading-goose/6.svg'; import svg7 from '../images/loading-goose/7.svg'; -const Example = () => { +const LoadingGoose = () => { const [currentFrame, setCurrentFrame] = useState(0); const frames = [svg1, svg2, svg3, svg4, svg5, svg6, svg7]; const frameCount = frames.length; @@ -15,10 +15,10 @@ const Example = () => { useEffect(() => { const interval = setInterval(() => { setCurrentFrame((prev) => (prev + 1) % frameCount); - }, 200); // 200ms for smoother animation, adjust as needed + }, 200); // 200ms for smoother animation return () => clearInterval(interval); - }, [frameCount]); // Add frameCount as dependency + }, [frameCount]); return (
@@ -27,4 +27,4 @@ const Example = () => { ); }; -export default Example; \ No newline at end of file +export default LoadingGoose; \ No newline at end of file