diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 70243f7a8e..bf2dd73e70 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -134,6 +134,7 @@ const Reasoning = memo((props: ReasoningProps) => { animateLabel={ smoothStreaming && effectiveIsSubmitting && Boolean(reasoningLabel?.trim()) } + shimmerLabel={effectiveIsSubmitting && isLast} />
) => void; @@ -50,6 +51,10 @@ export const ThinkingButton = memo( contentId: string; showCopyButton?: boolean; animateLabel?: boolean; + /** Reasoning is still being generated: carry the same shimmer a running + * tool call's label carries, so "thinking" reads as in-flight rather + * than as a settled disclosure. Off for finished thoughts. */ + shimmerLabel?: boolean; }) => { const localize = useLocalize(); const fontSize = useAtomValue(fontSizeAtom); @@ -93,15 +98,27 @@ export const ThinkingButton = memo( aria-hidden="true" /> + {/* The entrance and the shimmer both drive `animation-name`, so they + cannot share an element — and the nesting order is not a free + choice either. The clipped element paints the glyphs itself, so a + descendant's opacity cannot fade them: entrance outside and + shimmer inside fades correctly, while the reverse leaves the label + fully lit right through its own fade. + + `key` remounts the row so the entrance replays, and that restarts + the sweep with it. Reasoning labels revise on a 3s default against + a 4s sweep, so the restart is frequent; it reads as intentional + only while the entrance is there to cover it. With no entrance to + play, the row keeps its identity and the sweep runs unbroken. */} - {label} + {shimmerLabel ? {label} : label} {content && showCopyButton && ( diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/Reasoning.spec.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/Reasoning.spec.tsx new file mode 100644 index 0000000000..8c1c506e21 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/Reasoning.spec.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MessageContext } from '~/Providers'; +import Reasoning from '../Reasoning'; + +type ContextOverrides = { + isSubmitting?: boolean; + isLatestMessage?: boolean; +}; + +function renderReasoning( + { isSubmitting = true, isLatestMessage = true }: ContextOverrides = {}, + isLast = true, +) { + return render( + + + , + ); +} + +describe('Reasoning label shimmer', () => { + it('shimmers the label while the reasoning is still being generated', () => { + renderReasoning(); + expect(screen.getByText('Thinking...')).toHaveClass('shimmer'); + }); + + it('leaves settled thoughts unanimated', () => { + renderReasoning({ isSubmitting: false }); + expect(screen.getByText('Thoughts')).not.toHaveClass('shimmer'); + }); + + it('leaves an older row unanimated while a newer one streams', () => { + renderReasoning({ isLatestMessage: false }); + expect(screen.getByText('Thoughts')).not.toHaveClass('shimmer'); + }); + + it('leaves reasoning that has been overtaken by later content unanimated', () => { + renderReasoning({}, false); + expect(screen.getByText('Thoughts')).not.toHaveClass('shimmer'); + }); +}); diff --git a/client/src/components/Chat/Messages/Elapsed.tsx b/client/src/components/Chat/Messages/Elapsed.tsx index 2029d68598..01e9af3a2c 100644 --- a/client/src/components/Chat/Messages/Elapsed.tsx +++ b/client/src/components/Chat/Messages/Elapsed.tsx @@ -43,6 +43,10 @@ export const shouldShowElapsed = ({ * re-render on its account. The compact reading is hidden from assistive * technology in favor of a spoken equivalent; neither is an `aria-live` region, * so the tick never announces. + * + * The reading carries the same shimmer a running tool call's label carries. It + * only ever renders mid-generation (see `shouldShowElapsed`), so there is no + * settled state for the animation to misrepresent. */ const Elapsed = memo(function Elapsed({ index }: { index: number }) { const submissionStart = useRecoilValue(store.submissionStartFamily(index)); @@ -73,7 +77,7 @@ export const ElapsedTimer = memo(function ElapsedTimer({ start: startAt }: { sta * the alignment holds in RTL. */ return ( -