From 7ae15fdc71fba93fe792608a7449c105f88763b4 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:13:31 -0800 Subject: [PATCH] added reduce motion support for css animations and streaming text (#6551) --- ui/desktop/src/hooks/use-text-animator.tsx | 20 +++---- ui/desktop/src/hooks/useChatStream.ts | 70 +++++++++++++++++++--- ui/desktop/src/styles/main.css | 40 +++++++++++++ ui/desktop/src/styles/search.css | 9 +++ 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/ui/desktop/src/hooks/use-text-animator.tsx b/ui/desktop/src/hooks/use-text-animator.tsx index 6f848b92b2..d3002d0d37 100644 --- a/ui/desktop/src/hooks/use-text-animator.tsx +++ b/ui/desktop/src/hooks/use-text-animator.tsx @@ -6,7 +6,6 @@ interface TextSplitterOptions { splitTypeTypes?: ('lines' | 'words' | 'chars')[]; } -// Class to split text into lines, words, and characters for animation export class TextSplitter { textElement: HTMLElement; onResize: (() => void) | null; @@ -31,9 +30,7 @@ export class TextSplitter { } initResizeObserver() { - // Use a simpler approach to avoid type issues const resizeObserver = new ResizeObserver(() => { - // Just check the current width directly from the element if (this.textElement) { const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width); @@ -197,15 +194,11 @@ export class TextAnimator { } reset() { - // Clear all timeouts this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); this.activeTimeouts = []; - - // Cancel all animations this.activeAnimations.forEach((animation) => animation.cancel()); this.activeAnimations = []; - // Reset text content const chars = this.splitter.getChars(); chars.forEach((char, index) => { if (this.originalChars[index]) { @@ -219,6 +212,10 @@ interface UseTextAnimatorProps { text: string; } +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + export function useTextAnimator({ text }: UseTextAnimatorProps) { const elementRef = useRef(null); const animator = useRef(null); @@ -226,22 +223,23 @@ export function useTextAnimator({ text }: UseTextAnimatorProps) { useEffect(() => { if (!elementRef.current) return; - // Create animator + if (prefersReducedMotion()) { + return; + } + animator.current = new TextAnimator(elementRef.current); - // Small delay to ensure content is ready const timeoutId = setTimeout(() => { animator.current?.animate(); }, 100); - // Cleanup return () => { window.clearTimeout(timeoutId); if (animator.current) { animator.current.reset(); } }; - }, [text]); // Re-run when text changes + }, [text]); return elementRef; } diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e3ce5e5f0b..1b691e03e6 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -195,6 +195,12 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[ } } +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +const REDUCED_MOTION_BATCH_INTERVAL = 1000; + async function streamFromResponse( stream: AsyncIterable, initialMessages: Message[], @@ -203,6 +209,49 @@ async function streamFromResponse( sessionId: string ): Promise { let currentMessages = initialMessages; + const reduceMotion = prefersReducedMotion(); + let latestTokenState: TokenState | null = null; + let latestChatState: ChatState = ChatState.Streaming; + let lastBatchUpdate = Date.now(); + let hasPendingUpdate = false; + + const flushBatchedUpdates = () => { + if (reduceMotion && hasPendingUpdate) { + if (latestTokenState) { + dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState }); + } + dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); + dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState }); + hasPendingUpdate = false; + lastBatchUpdate = Date.now(); + } + }; + + const maybeUpdateUI = ( + tokenState: TokenState, + chatState: ChatState, + forceImmediate = false + ) => { + if (!reduceMotion) { + dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); + dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); + dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); + } else if (forceImmediate) { + dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); + dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); + dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); + hasPendingUpdate = false; + lastBatchUpdate = Date.now(); + } else { + latestTokenState = tokenState; + latestChatState = chatState; + hasPendingUpdate = true; + const now = Date.now(); + if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) { + flushBatchedUpdates(); + } + } + }; try { for await (const event of stream) { @@ -221,24 +270,23 @@ async function streamFromResponse( ); if (hasToolConfirmation || hasElicitation) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput }); + maybeUpdateUI(event.token_state, ChatState.WaitingForUserInput, true); } else if (getCompactingMessage(msg)) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Compacting }); + maybeUpdateUI(event.token_state, ChatState.Compacting); } else if (getThinkingMessage(msg)) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking }); + maybeUpdateUI(event.token_state, ChatState.Thinking); } else { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming }); + maybeUpdateUI(event.token_state, ChatState.Streaming); } - - dispatch({ type: 'SET_TOKEN_STATE', payload: event.token_state }); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); break; } case 'Error': { + flushBatchedUpdates(); onFinish('Stream error: ' + event.error); return; } case 'Finish': { + flushBatchedUpdates(); onFinish(); return; } @@ -247,7 +295,11 @@ async function streamFromResponse( } case 'UpdateConversation': { currentMessages = event.conversation; - dispatch({ type: 'SET_MESSAGES', payload: event.conversation }); + if (!reduceMotion) { + dispatch({ type: 'SET_MESSAGES', payload: event.conversation }); + } else { + hasPendingUpdate = true; + } break; } case 'Notification': { @@ -260,8 +312,10 @@ async function streamFromResponse( } } + flushBatchedUpdates(); onFinish(); } catch (error) { + flushBatchedUpdates(); if (error instanceof Error && error.name !== 'AbortError') { onFinish('Stream error: ' + errorMessage(error)); } diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 37baa27535..eeb6d33929 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -347,6 +347,46 @@ } } +/* Global reduced motion support - applies when user has system preference set */ +@media (prefers-reduced-motion: reduce) { + /* Disable all CSS animations and transitions, except for toasts which need transitions for auto-dismiss */ + *:not(.Toastify *), + *:not(.Toastify *)::before, + *:not(.Toastify *)::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + /* Specific animation classes that should be disabled */ + .animate-shimmer, + .animate-spin, + .animate-pulse, + .animate-bounce, + [class*='animate-']:not(.Toastify *) { + animation: none !important; + } + + /* Page transitions */ + .page-transition { + animation: none !important; + opacity: 1 !important; + } + + /* Goose icon entrance animation */ + .goose-icon-entrance { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } + + /* Wind/rain animation in welcome logo */ + [class*='animate-[wind'] { + animation: none !important; + } +} + /* Toast close button styling */ .Toastify__close-button { color: var(--text-prominent-inverse) !important; diff --git a/ui/desktop/src/styles/search.css b/ui/desktop/src/styles/search.css index 894199bb0d..608ab83463 100644 --- a/ui/desktop/src/styles/search.css +++ b/ui/desktop/src/styles/search.css @@ -41,3 +41,12 @@ animation: collapseUp 0.15s ease-out forwards; overflow: hidden; } + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .search-bar-enter, + .search-bar-exit { + animation: none; + max-height: var(--search-bar-height); + } +}