Skip to content

Commit 7ae15fd

Browse files
authored
added reduce motion support for css animations and streaming text (#6551)
1 parent 932923e commit 7ae15fd

4 files changed

Lines changed: 120 additions & 19 deletions

File tree

ui/desktop/src/hooks/use-text-animator.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ interface TextSplitterOptions {
66
splitTypeTypes?: ('lines' | 'words' | 'chars')[];
77
}
88

9-
// Class to split text into lines, words, and characters for animation
109
export class TextSplitter {
1110
textElement: HTMLElement;
1211
onResize: (() => void) | null;
@@ -31,9 +30,7 @@ export class TextSplitter {
3130
}
3231

3332
initResizeObserver() {
34-
// Use a simpler approach to avoid type issues
3533
const resizeObserver = new ResizeObserver(() => {
36-
// Just check the current width directly from the element
3734
if (this.textElement) {
3835
const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width);
3936

@@ -197,15 +194,11 @@ export class TextAnimator {
197194
}
198195

199196
reset() {
200-
// Clear all timeouts
201197
this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
202198
this.activeTimeouts = [];
203-
204-
// Cancel all animations
205199
this.activeAnimations.forEach((animation) => animation.cancel());
206200
this.activeAnimations = [];
207201

208-
// Reset text content
209202
const chars = this.splitter.getChars();
210203
chars.forEach((char, index) => {
211204
if (this.originalChars[index]) {
@@ -219,29 +212,34 @@ interface UseTextAnimatorProps {
219212
text: string;
220213
}
221214

215+
function prefersReducedMotion(): boolean {
216+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
217+
}
218+
222219
export function useTextAnimator({ text }: UseTextAnimatorProps) {
223220
const elementRef = useRef<HTMLDivElement>(null);
224221
const animator = useRef<TextAnimator | null>(null);
225222

226223
useEffect(() => {
227224
if (!elementRef.current) return;
228225

229-
// Create animator
226+
if (prefersReducedMotion()) {
227+
return;
228+
}
229+
230230
animator.current = new TextAnimator(elementRef.current);
231231

232-
// Small delay to ensure content is ready
233232
const timeoutId = setTimeout(() => {
234233
animator.current?.animate();
235234
}, 100);
236235

237-
// Cleanup
238236
return () => {
239237
window.clearTimeout(timeoutId);
240238
if (animator.current) {
241239
animator.current.reset();
242240
}
243241
};
244-
}, [text]); // Re-run when text changes
242+
}, [text]);
245243

246244
return elementRef;
247245
}

ui/desktop/src/hooks/useChatStream.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
195195
}
196196
}
197197

198+
function prefersReducedMotion(): boolean {
199+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
200+
}
201+
202+
const REDUCED_MOTION_BATCH_INTERVAL = 1000;
203+
198204
async function streamFromResponse(
199205
stream: AsyncIterable<MessageEvent>,
200206
initialMessages: Message[],
@@ -203,6 +209,49 @@ async function streamFromResponse(
203209
sessionId: string
204210
): Promise<void> {
205211
let currentMessages = initialMessages;
212+
const reduceMotion = prefersReducedMotion();
213+
let latestTokenState: TokenState | null = null;
214+
let latestChatState: ChatState = ChatState.Streaming;
215+
let lastBatchUpdate = Date.now();
216+
let hasPendingUpdate = false;
217+
218+
const flushBatchedUpdates = () => {
219+
if (reduceMotion && hasPendingUpdate) {
220+
if (latestTokenState) {
221+
dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState });
222+
}
223+
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
224+
dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState });
225+
hasPendingUpdate = false;
226+
lastBatchUpdate = Date.now();
227+
}
228+
};
229+
230+
const maybeUpdateUI = (
231+
tokenState: TokenState,
232+
chatState: ChatState,
233+
forceImmediate = false
234+
) => {
235+
if (!reduceMotion) {
236+
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
237+
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
238+
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
239+
} else if (forceImmediate) {
240+
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
241+
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
242+
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
243+
hasPendingUpdate = false;
244+
lastBatchUpdate = Date.now();
245+
} else {
246+
latestTokenState = tokenState;
247+
latestChatState = chatState;
248+
hasPendingUpdate = true;
249+
const now = Date.now();
250+
if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) {
251+
flushBatchedUpdates();
252+
}
253+
}
254+
};
206255

207256
try {
208257
for await (const event of stream) {
@@ -221,24 +270,23 @@ async function streamFromResponse(
221270
);
222271

223272
if (hasToolConfirmation || hasElicitation) {
224-
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput });
273+
maybeUpdateUI(event.token_state, ChatState.WaitingForUserInput, true);
225274
} else if (getCompactingMessage(msg)) {
226-
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Compacting });
275+
maybeUpdateUI(event.token_state, ChatState.Compacting);
227276
} else if (getThinkingMessage(msg)) {
228-
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking });
277+
maybeUpdateUI(event.token_state, ChatState.Thinking);
229278
} else {
230-
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
279+
maybeUpdateUI(event.token_state, ChatState.Streaming);
231280
}
232-
233-
dispatch({ type: 'SET_TOKEN_STATE', payload: event.token_state });
234-
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
235281
break;
236282
}
237283
case 'Error': {
284+
flushBatchedUpdates();
238285
onFinish('Stream error: ' + event.error);
239286
return;
240287
}
241288
case 'Finish': {
289+
flushBatchedUpdates();
242290
onFinish();
243291
return;
244292
}
@@ -247,7 +295,11 @@ async function streamFromResponse(
247295
}
248296
case 'UpdateConversation': {
249297
currentMessages = event.conversation;
250-
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
298+
if (!reduceMotion) {
299+
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
300+
} else {
301+
hasPendingUpdate = true;
302+
}
251303
break;
252304
}
253305
case 'Notification': {
@@ -260,8 +312,10 @@ async function streamFromResponse(
260312
}
261313
}
262314

315+
flushBatchedUpdates();
263316
onFinish();
264317
} catch (error) {
318+
flushBatchedUpdates();
265319
if (error instanceof Error && error.name !== 'AbortError') {
266320
onFinish('Stream error: ' + errorMessage(error));
267321
}

ui/desktop/src/styles/main.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,46 @@
347347
}
348348
}
349349

350+
/* Global reduced motion support - applies when user has system preference set */
351+
@media (prefers-reduced-motion: reduce) {
352+
/* Disable all CSS animations and transitions, except for toasts which need transitions for auto-dismiss */
353+
*:not(.Toastify *),
354+
*:not(.Toastify *)::before,
355+
*:not(.Toastify *)::after {
356+
animation-duration: 0.01ms !important;
357+
animation-iteration-count: 1 !important;
358+
transition-duration: 0.01ms !important;
359+
scroll-behavior: auto !important;
360+
}
361+
362+
/* Specific animation classes that should be disabled */
363+
.animate-shimmer,
364+
.animate-spin,
365+
.animate-pulse,
366+
.animate-bounce,
367+
[class*='animate-']:not(.Toastify *) {
368+
animation: none !important;
369+
}
370+
371+
/* Page transitions */
372+
.page-transition {
373+
animation: none !important;
374+
opacity: 1 !important;
375+
}
376+
377+
/* Goose icon entrance animation */
378+
.goose-icon-entrance {
379+
animation: none !important;
380+
opacity: 1 !important;
381+
transform: none !important;
382+
}
383+
384+
/* Wind/rain animation in welcome logo */
385+
[class*='animate-[wind'] {
386+
animation: none !important;
387+
}
388+
}
389+
350390
/* Toast close button styling */
351391
.Toastify__close-button {
352392
color: var(--text-prominent-inverse) !important;

ui/desktop/src/styles/search.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,12 @@
4141
animation: collapseUp 0.15s ease-out forwards;
4242
overflow: hidden;
4343
}
44+
45+
/* Reduced motion support */
46+
@media (prefers-reduced-motion: reduce) {
47+
.search-bar-enter,
48+
.search-bar-exit {
49+
animation: none;
50+
max-height: var(--search-bar-height);
51+
}
52+
}

0 commit comments

Comments
 (0)