mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
added reduce motion support for css animations and streaming text (#6551)
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const animator = useRef<TextAnimator | null>(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;
|
||||
}
|
||||
|
||||
@@ -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<MessageEvent>,
|
||||
initialMessages: Message[],
|
||||
@@ -203,6 +209,49 @@ async function streamFromResponse(
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user