diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index d0dffc7238..11f163d17b 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -6,7 +6,7 @@ import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { FADE_HYDRATION_THRESHOLD } from './animate'; import { useMessageContext } from '~/Providers'; import MarkdownBlocks from './MarkdownBlocks'; -import { preprocessLaTeX, cn } from '~/utils'; +import { preprocessLaTeX } from '~/utils'; import store from '~/store'; type TContentProps = { @@ -50,12 +50,7 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont return (

- +

); diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index d20c5f71e7..987586c6ef 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -27,21 +27,17 @@ const parseThinkingContent = (text: string) => { }; }; -const LoadingFallback = () => { - const smoothStreaming = useSmoothStreaming(); - - return ( -
-
-
-

- -

-
+const LoadingFallback = () => ( +
+
+
+

+ +

- ); -}; +
+); const ErrorBox = ({ children, diff --git a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx index a5487aa0f6..6f51951f9b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx @@ -1,17 +1,13 @@ import { memo } from 'react'; -import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; -import { cn } from '~/utils'; /** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */ const EmptyTextPart = memo(() => { - const smoothStreaming = useSmoothStreaming(); - return (

- +

diff --git a/client/src/style.css b/client/src/style.css index e465ebb7ad..aa0fdb90a3 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -640,39 +640,6 @@ pre { transform: scale(0.95); } -.blink { - animation: blink 1s linear infinite; -} -@keyframes blink { - 0% { - opacity: 1; - } - 50% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes blink { - 0% { - opacity: 1; - } - 79% { - opacity: 1; - } - 80% { - opacity: 0; - } - 99% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - .prose { color: var(--tw-prose-body); max-width: 65ch; @@ -1642,16 +1609,6 @@ html { text-decoration-line: underline; text-underline-offset: 2px; } -@-webkit-keyframes blink { - to { - visibility: hidden; - } -} -@keyframes blink { - to { - visibility: hidden; - } -} .hidden-visibility { visibility: hidden; } @@ -1719,49 +1676,46 @@ html { } } -@-webkit-keyframes pulseSize { +/* Pre-response dot: enters on the streamed-text fade curve, then breathes + on opacity — replaces the legacy scale throb so waiting and text arrival + share one motion language. + + Custom CSS exception: this indicator is a `::after` pseudo-element on the + `.result-thinking` hook, shown only while an ancestor carries `.submitting` + and the hook itself is `:empty`. No `@librechat/client` primitive can + express "render only while an ancestor is in state X and I have no + children" — a component would have to own that state, which the streaming + markdown path deliberately keeps in the DOM so the dot costs no React + renders per token. Scoped to the one selector, colored from + `--text-primary` so both themes follow, and disabled under + prefers-reduced-motion. */ +@keyframes lc-dot-breathe { 0%, - to { - -webkit-transform: scaleX(1); - transform: scaleX(1); + 100% { + opacity: 1; } 50% { - -webkit-transform: scale3d(1.25, 1.25, 1); - transform: scale3d(1.25, 1.25, 1); - } -} -@keyframes pulseSize { - 0%, - to { - -webkit-transform: scaleX(1); - transform: scaleX(1); - } - 50% { - -webkit-transform: scale3d(1.25, 1.25, 1); - transform: scale3d(1.25, 1.25, 1); + opacity: 0.3; } } .submitting .result-thinking:empty:last-child:after { - -webkit-font-smoothing: subpixel-antialiased; - -webkit-animation: pulseSize 1.25s ease-in-out infinite; - animation: pulseSize 1.25s ease-in-out infinite; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - background-color: #0d0d0d; + animation: + lc-fade-in 250ms ease-out both, + lc-dot-breathe 1.5s ease-in-out 250ms infinite; background-color: rgb(var(--text-primary)); border-radius: 50%; - box-sizing: border-box; content: ' '; display: block; height: 12px; position: absolute; top: -11px; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-transform-origin: center; - transform-origin: center; width: 12px; - will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .submitting .result-thinking:empty:last-child:after { + animation: none; + } } .shadow-stroke { @@ -1930,30 +1884,6 @@ html { } } -/* Pre-first-token dot, restyled to match the word fade: it fades in on the - same curve rather than popping, then breathes on opacity instead of the - classic size throb, so "run starting" and "text arriving" read as one - system. Only applied while the fade is active; without it the dot keeps - its original pulseSize behavior. */ -@keyframes lc-dot-breathe { - 0%, - 100% { - opacity: 0.35; - transform: translateZ(0) scale(0.85); - } - 50% { - opacity: 1; - transform: translateZ(0) scale(1); - } -} - -.submitting .result-thinking.result-thinking-fade:empty:last-child:after { - animation: - lc-fade-in 250ms ease-out both, - lc-dot-breathe 1.6s ease-in-out 250ms infinite; - will-change: opacity, transform; -} - .webkit-dark-styles, .webkit-dark-styles:focus { background-clip: content-box;