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 (
-
+
{localize(labels.key, labels.values)}
{localize(labels.announcedKey, labels.announcedValues)}
diff --git a/client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx b/client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx
index 15da454316..e83054aa3e 100644
--- a/client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx
+++ b/client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx
@@ -37,6 +37,9 @@ describe('Elapsed', () => {
/** The 6px inline-start inset that lines the reading up with the
* streaming dot and the hover-button glyphs that replace it. */
expect(screen.getByTestId('stream-elapsed').parentElement).toHaveClass('ps-1.5');
+ /** The same shimmer a running tool call's label carries: the timer only
+ * ever renders mid-generation, so it never animates a settled reading. */
+ expect(screen.getByTestId('stream-elapsed')).toHaveClass('shimmer');
advance(54_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^59s$/);
diff --git a/client/src/style.css b/client/src/style.css
index 5212a03f19..ae0a05dfae 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -153,6 +153,8 @@ html {
--text-secondary: var(--gray-600);
--text-secondary-alt: var(--gray-500);
--text-tertiary: var(--gray-500);
+ --shimmer-dip: 129 130 134;
+ --shimmer-dip-alpha: 0.18;
--text-warning: var(--amber-700);
--text-destructive: var(--red-600);
--link: var(--blue-600);
@@ -259,6 +261,10 @@ html {
--text-secondary: var(--gray-300);
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-400);
+ --shimmer-base: 255 255 255;
+ --shimmer-base-alpha: 0.8;
+ --shimmer-dip: 179 179 179;
+ --shimmer-dip-alpha: 0.25;
--text-warning: var(--amber-500);
--text-destructive: var(--red-400);
--link: var(--blue-400);
@@ -2488,14 +2494,22 @@ html[data-input-modality='keyboard']
}
}
+/* The sweep every in-flight label shares: a running tool call, streaming
+ reasoning, the elapsed timer. Painted from theme roles rather than fixed
+ black/white keyed off `.dark`, so a stored or custom theme restates it the
+ way it restates any other color. Unset, the base falls back to whatever
+ `--text-primary` is in scope AT THE LABEL — resolving it in a `:root`
+ declaration instead would freeze the default and ignore theme scopes applied
+ further down. Dark declares a base outright: its established value is dimmer
+ than that theme's text color rather than equal to it. */
.shimmer {
display: inline-block;
position: relative;
background: linear-gradient(
90deg,
- rgb(33, 33, 33) 25%,
- rgba(129, 130, 134, 0.18) 50%,
- rgb(33, 33, 33) 75%
+ rgb(var(--shimmer-base, var(--text-primary)) / var(--shimmer-base-alpha, 1)) 25%,
+ rgb(var(--shimmer-dip) / var(--shimmer-dip-alpha, 1)) 50%,
+ rgb(var(--shimmer-base, var(--text-primary)) / var(--shimmer-base-alpha, 1)) 75%
);
background-size: 200% 100%;
background-clip: text;
@@ -2504,18 +2518,16 @@ html[data-input-modality='keyboard']
animation: shimmer 4s linear infinite;
}
-.dark .shimmer {
- background: linear-gradient(
- 90deg,
- rgba(255, 255, 255, 0.8) 25%,
- rgba(179, 179, 179, 0.25) 50%,
- rgba(255, 255, 255, 0.8) 75%
- );
- background-size: 200% 100%;
- background-clip: text;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- animation: shimmer 4s linear infinite;
+/* Halting the animation alone is not a fallback: the background would settle
+ at position 0 and show only half the sweep, stranding the tail of the label
+ in the near-invisible dip. Drop the gradient and hand the glyphs back to the
+ color they would have had. */
+@media (prefers-reduced-motion: reduce) {
+ .shimmer {
+ background: none;
+ animation: none;
+ -webkit-text-fill-color: currentcolor;
+ }
}
.sharepoint-picker-bg {
diff --git a/packages/client/src/theme/registry.spec.ts b/packages/client/src/theme/registry.spec.ts
index 6a11009c2b..8a7badfdd3 100644
--- a/packages/client/src/theme/registry.spec.ts
+++ b/packages/client/src/theme/registry.spec.ts
@@ -50,6 +50,44 @@ describe('theme registry', () => {
expect(dark.appearance).toEqual(defaultAppearance);
});
+ /** Themes predate the shimmer stops, so an omission means "not written yet",
+ * not "wants LibreChat's sweep". Filling it from the bundled base would light
+ * a white-text theme's in-flight labels in the stock near-black. */
+ it('derives an omitted shimmer base from a theme that restates its text', () => {
+ const inverted = resolveTheme(
+ {
+ version: 1,
+ name: 'inverted-reference',
+ modes: { light: { colors: { 'rgb-text-primary': '255 255 255' } } },
+ },
+ 'light',
+ );
+
+ expect(inverted.colors['rgb-shimmer-base']).toBe('255 255 255');
+ expect(inverted.colors['rgb-shimmer-dip']).toBe(defaultTheme['rgb-shimmer-dip']);
+ });
+
+ it('leaves a theme that names its own shimmer base alone', () => {
+ const explicit = resolveTheme(
+ {
+ version: 1,
+ name: 'explicit-reference',
+ modes: {
+ light: { colors: { 'rgb-text-primary': '255 255 255', 'rgb-shimmer-base': '10 20 30' } },
+ },
+ },
+ 'light',
+ );
+
+ expect(explicit.colors['rgb-shimmer-base']).toBe('10 20 30');
+ });
+
+ it('keeps the bundled shimmer base for a theme that restates nothing', () => {
+ expect(resolveTheme(compactTheme, 'dark').colors['rgb-shimmer-base']).toBe(
+ darkTheme['rgb-shimmer-base'],
+ );
+ });
+
it('resolves provider brand tokens and lets a theme override them', () => {
const defaults = resolveTheme(libreChatTheme, 'light');
expect(defaults.brands['provider-anthropic']).toBe('#d09a74');
diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts
index 479822469d..c9e5aec448 100644
--- a/packages/client/src/theme/registry.ts
+++ b/packages/client/src/theme/registry.ts
@@ -274,12 +274,30 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
customColors?.['rgb-surface-hover'] !== undefined
? { 'rgb-surface-composer-hover': customColors['rgb-surface-hover'] }
: {};
+ /**
+ * Themes written before the shimmer stops existed cannot name them, and
+ * filling the omission from the bundled base would pin their in-flight labels
+ * to LibreChat's own sweep — a theme that restates its text as white would
+ * light every label in the stock near-black. A theme that wants the bundled
+ * sweep alongside custom text still gets it by naming the stop, the way
+ * `rgb-surface-composer-hover` opts out of its own fallback above.
+ */
+ const shimmerBaseFallback =
+ customColors?.['rgb-shimmer-base'] === undefined &&
+ customColors?.['rgb-text-primary'] !== undefined
+ ? { 'rgb-shimmer-base': customColors['rgb-text-primary'] }
+ : {};
return {
version: THEME_VERSION,
name: theme.name,
mode,
- colors: { ...baseColors, ...customColors, ...composerHoverFallback } as Required,
+ colors: {
+ ...baseColors,
+ ...customColors,
+ ...composerHoverFallback,
+ ...shimmerBaseFallback,
+ } as Required,
appearance: { ...defaultAppearance, ...definition?.appearance },
brands: { ...defaultBrands, ...theme.brands },
};
diff --git a/packages/client/src/theme/themes/dark.ts b/packages/client/src/theme/themes/dark.ts
index 69cabf4855..b2e377f76c 100644
--- a/packages/client/src/theme/themes/dark.ts
+++ b/packages/client/src/theme/themes/dark.ts
@@ -12,6 +12,8 @@ export const darkTheme: IThemeRGB = {
'rgb-text-tertiary': '153 150 150', // #999696 (gray-400)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
'rgb-text-destructive': '248 113 113', // #f87171 (red-400)
+ 'rgb-shimmer-base': '255 255 255', // #ffffff, carried at 0.8 alpha
+ 'rgb-shimmer-dip': '179 179 179', // #b3b3b3
// Link and accent colors
'rgb-link': '96 165 250', // #60a5fa (blue-400)
diff --git a/packages/client/src/theme/themes/default.ts b/packages/client/src/theme/themes/default.ts
index 503d02ddc4..3d3949cc8b 100644
--- a/packages/client/src/theme/themes/default.ts
+++ b/packages/client/src/theme/themes/default.ts
@@ -12,6 +12,8 @@ export const defaultTheme: IThemeRGB = {
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '180 83 9', // #b45309 (amber-700)
'rgb-text-destructive': '220 38 38', // #dc2626 (red-600)
+ 'rgb-shimmer-base': '33 33 33', // #212121 (gray-800), matching text-primary
+ 'rgb-shimmer-dip': '129 130 134', // #818286
// Link and accent colors
'rgb-link': '37 99 235', // #2563eb (blue-600)
diff --git a/packages/client/src/theme/types/index.ts b/packages/client/src/theme/types/index.ts
index 4a840cb38d..ac079885df 100644
--- a/packages/client/src/theme/types/index.ts
+++ b/packages/client/src/theme/types/index.ts
@@ -10,6 +10,11 @@ export interface IThemeRGB {
'rgb-text-tertiary'?: string;
'rgb-text-warning'?: string;
'rgb-text-destructive'?: string;
+ /** Bright and dipped stops of the in-flight label sweep (`.shimmer`). Their
+ * opacities stay in CSS as `--shimmer-*-alpha`, the way the border roles
+ * keep `--border-*-alpha`. */
+ 'rgb-shimmer-base'?: string;
+ 'rgb-shimmer-dip'?: string;
// Link and accent colors
'rgb-link'?: string;
@@ -113,6 +118,8 @@ export interface IThemeVariables {
'--text-tertiary': string;
'--text-warning': string;
'--text-destructive': string;
+ '--shimmer-base': string;
+ '--shimmer-dip': string;
'--link': string;
'--link-hover': string;
'--link-visited': string;
diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts
index 020907d9ab..b1e7864c76 100644
--- a/packages/client/src/theme/utils/applyTheme.spec.ts
+++ b/packages/client/src/theme/utils/applyTheme.spec.ts
@@ -1,5 +1,9 @@
import type { ThemeDefinition } from '../types';
-import applyTheme, { applyResolvedTheme, clearAppliedTheme } from './applyTheme';
+import applyTheme, {
+ applyResolvedTheme,
+ clearAppliedTheme,
+ themeOwnedProperties,
+} from './applyTheme';
import { defaultTheme } from '../themes/default';
import { resolveTheme } from '../registry';
@@ -130,6 +134,57 @@ describe('applyTheme', () => {
expect(root.style.getPropertyValue('--theme-motion-fast')).toBe('80ms');
});
+ /** The sweep under an in-flight label is painted in CSS, so it is only
+ * themeable if its stops are theme-owned properties. A dark theme is the
+ * case that matters: `style.css` declares a `.dark` base outright, which a
+ * theme can only outrank by having these applied to the document element. */
+ it('lets a dark theme restate the in-flight label sweep', () => {
+ const root = document.documentElement;
+
+ applyResolvedTheme(
+ resolveTheme(
+ {
+ version: 1,
+ name: 'shimmer-reference',
+ modes: {
+ dark: { colors: { 'rgb-shimmer-base': '12 200 180', 'rgb-shimmer-dip': '4 60 55' } },
+ },
+ },
+ 'dark',
+ ),
+ root,
+ );
+
+ expect(root.style.getPropertyValue('--shimmer-base')).toBe('12 200 180');
+ expect(root.style.getPropertyValue('--shimmer-dip')).toBe('4 60 55');
+ expect(themeOwnedProperties).toEqual(
+ expect.arrayContaining(['--shimmer-base', '--shimmer-dip']),
+ );
+
+ clearAppliedTheme(root);
+ expect(root.style.getPropertyValue('--shimmer-base')).toBe('');
+ });
+
+ /** Legacy `themeRGB` themes predate the shimmer stops and this adapter applies
+ * only the keys they name, so a stored theme would otherwise keep the stock
+ * sweep while the rest of its palette moved — and in dark the CSS cannot
+ * recover, since `.dark` declares a base that outranks the fallback. */
+ it('carries a legacy theme without shimmer keys onto its own text color', () => {
+ const root = document.documentElement;
+
+ applyTheme({ 'rgb-text-primary': '10 20 30' }, root);
+
+ expect(root.style.getPropertyValue('--shimmer-base')).toBe('10 20 30');
+ });
+
+ it('leaves a legacy theme that names its own shimmer base alone', () => {
+ const root = document.documentElement;
+
+ applyTheme({ 'rgb-text-primary': '10 20 30', 'rgb-shimmer-base': '90 80 70' }, root);
+
+ expect(root.style.getPropertyValue('--shimmer-base')).toBe('90 80 70');
+ });
+
it('clears only properties owned by the theme module', () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', '1 2 3');
diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts
index 38c941b564..e969d25a35 100644
--- a/packages/client/src/theme/utils/applyTheme.ts
+++ b/packages/client/src/theme/utils/applyTheme.ts
@@ -33,6 +33,20 @@ function mapColors(colors: IThemeRGB): Array<[string, string]> {
variables.push(['--surface-composer-hover', colors['rgb-surface-hover']]);
}
+ /**
+ * Stored and environment themes predate the shimmer stops, and this adapter
+ * applies only the keys a theme names — so without this they would keep the
+ * stock sweep while every other color moved, and in dark mode the CSS cannot
+ * recover: `.dark` declares a base outright, so the `--text-primary` fallback
+ * never runs. The bright stop follows the theme's primary text color, which
+ * is what it already resolves to in light. The dip has no legacy counterpart
+ * and stays at its default: it is the faded half of the sweep, carried at low
+ * alpha, so it reads as dimmed against any base.
+ */
+ if (colors['rgb-shimmer-base'] === undefined && colors['rgb-text-primary'] !== undefined) {
+ variables.push(['--shimmer-base', colors['rgb-text-primary']]);
+ }
+
return variables;
}