🎯 fix: Keep the Thinking Label on the Lightbulb's Axis While Shimmering (#15437)

The shimmer PR (#15431) nested the label inside a span carrying `.shimmer`,
which is `display: inline-block`, alongside `truncate`. An inline-block whose
overflow is not visible takes its baseline from its bottom margin edge rather
than from its last line box, so the label row's line box grew from 18px to 23px
and the text lifted 2px while the lightbulb — pinned to the row's 30px centre by
the copy button — stayed put. Every streaming reasoning header sat off its own
icon's axis until the label settled back to "Thoughts".

The sweep now rides the label row itself. That row is a flex item, so
`.shimmer`'s `inline-block` is blockified away and the text lands exactly where
the un-shimmered label lands: measured against the real component, both states
report an 18px box and the same text centre, where the nested form reported
23px and a 2px offset.

The nested span survives only for the generated-label entrance, which cannot
share an element with the sweep because both drive `animation-name`. It keeps
the entrance outside — the clipped element paints the glyphs itself, so a
descendant's opacity cannot fade them — and takes `align-top` so the baseline
rule above cannot reopen the gap there.

Found by rendering the real component in a browser; a hand-built reproduction of
the same classes did not show it, and neither did the DOM assertions, which is
why the regression shipped.
This commit is contained in:
Danny Avila 2026-08-31 21:15:28 -04:00 • committed by GitHub
parent cceb936655
commit 47dbcb4910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 32 additions and 7 deletions

View file

@ -98,12 +98,19 @@ export const ThinkingButton = memo(
aria-hidden="true"
/>
</span>
{/* 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 ? <span className="shimmer max-w-full truncate">{label}</span> : label}
{shimmerLabel && animateLabel ? (
<span className="shimmer max-w-full truncate align-top">{label}</span>
) : (
label
)}
</span>
</button>
{content && showCopyButton && (

View file

@ -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');