diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index 3be18b6056..6e142c11e8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -98,12 +98,19 @@ 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. + {/* The sweep rides the label row itself. That row is a flex item, so + `.shimmer`'s `inline-block` is blockified away and the text sits + exactly where the un-shimmered label sits — which matters, because + an `inline-block` carrying `truncate` takes its baseline from its + bottom margin edge, growing the line box and lifting the label off + the icon's axis. + + Only the generated-label entrance forces a nested span: it and the + sweep both drive `animation-name`, so they cannot share an element. + There the entrance must stay outside — the clipped element paints + the glyphs itself, so a descendant's opacity cannot fade them — and + the inner span takes `align-top` to keep the baseline rule above + from reopening the same gap. `key` remounts the row so the entrance replays, and that restarts the sweep with it. Reasoning labels revise on a 3s default against @@ -114,11 +121,16 @@ export const ThinkingButton = memo( key={animateLabel ? label : undefined} className={cn( 'min-w-0 truncate text-left', + shimmerLabel && !animateLabel && 'shimmer', animateLabel && 'duration-300 ease-out animate-in fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none', )} > - {shimmerLabel ? {label} : label} + {shimmerLabel && animateLabel ? ( + {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 index 8c1c506e21..6a77cca744 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/Reasoning.spec.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/Reasoning.spec.tsx @@ -27,6 +27,19 @@ describe('Reasoning label shimmer', () => { expect(screen.getByText('Thinking...')).toHaveClass('shimmer'); }); + /** The sweep has to ride the label row itself rather than a span nested inside + * it. That row is a flex item, so `.shimmer`'s `inline-block` is blockified + * away; nested, it is an inline-block whose `truncate` moves its baseline to + * its bottom margin edge, which grew the line box 18px -> 23px and lifted the + * label 2px off the lightbulb's axis for the whole of a generation. */ + it('keeps the sweep on the label row instead of a nested span', () => { + renderReasoning(); + const label = screen.getByText('Thinking...'); + + expect(label).toHaveClass('truncate'); + expect(label.querySelector('.shimmer')).toBeNull(); + }); + it('leaves settled thoughts unanimated', () => { renderReasoning({ isSubmitting: false }); expect(screen.getByText('Thoughts')).not.toHaveClass('shimmer');