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