📐 feat: Match the Message Column to the Composer (#14851)

* feat: Match the message column to the composer width

Give messages the same max-width and horizontal padding as ChatForm,
reserve the scrollbar gutter on the composer wrapper, and drop the
65ch prose cap so the body fills that column.

* style: Drop the assistant avatar gutter

Keep the icon and provider name on the same left edge as the message
body. Mid-message author headers and steer bubbles no longer outdent
past a column that no longer exists.

* style: Reveal the timestamp on the message header bar

Put the icon, provider name, and datetime on one full-width row, and
show the time only when the message is hovered or focused. CSS on
.message-render wins the hover hide that Tailwind group-hover lost.

* style: Align scroll-to-bottom with the chat column

Sit the control in the same padded column as the composer, swap the
hard-coded disc for a themed outline Button, and fade it in on an
8px rise instead of a scale pop.

* feat: Crossfade the provider name to the model on hover

Swap the assistant header label to the real model name when the
provider is hovered or focused. Skip agent_* document ids so the
hover text is only a model name.

* fix: Reserve the message column gutter without clipping the composer

`scrollbar-gutter: stable` only holds its band back while the element is a
scroll container, and a scroll container clips. Wrapping the composer in one
put the in-flight steer overlay outside the clip: it is painted above the
composer's top edge, so for the whole run a submitted steer was invisible and
its cancel unreachable. The scroll-to-bottom wrapper had the same problem in
a smaller way, cutting off the button's focus ring.

Reserve the same band with padding instead, sized by the width the app already
gives its own scrollbars, so both columns still line up with the messages
without either becoming a scroll container.

Also scopes the header label crossfade to the two labelled spans, and drops
its `:focus-within` rules, which no focusable descendant can ever trigger.

* fix: Keep document ids out of the header and name the model to screen readers

An Assistants-endpoint message keys the assistant map by `assistant.id`, so
its `model` field holds an `asst_` id, not a model name. The header label only
skipped `agent_`, so it crossfaded the assistant's name into an internal id.
Skip both prefixes, and offer `assistant.model` ahead of the message field in
the callers that already resolved the assistant.

The crossfade itself is pointer-only: the model span is `aria-hidden` and
nothing in the label can take focus, so keyboard and screen reader users had
no path to the value at all. Carry the model in text that never hides, which
puts it in the header's accessible name alongside the author and the time.

* refactor: Own the header crossfade in the component

The provider-to-model crossfade lived in global CSS even though HeaderLabel
is its only consumer. Tailwind expresses the whole effect: a named group for
the hover scope, one grid cell shared by both labels, and the existing resize
duration and easing variables. Reduced motion now follows the same
motion-reduce convention as the rest of the client.

* fix: Return a defined model name from the header lookup

Array.find over nullable candidates widens the return to include null, which
tsc rejects against the declared string | undefined. Narrow with a predicate
and sort the imports the pre-commit hook rewrote.

* fix: Keep the model reachable by keyboard and the scroll button inert

The header crossfade was pointer-only, so a sighted keyboard user never saw
the model name; the screen-reader copy covered announcement but not sight.
Focusing anything in the message row now swaps the label too, the same hook
the timestamp already reveals itself with.

The scroll-to-bottom wrapper spans the column and stays inert so it never
swallows clicks meant for the thread, which left the button to opt back into
pointer events. A descendant that opts in is hit-testable however its parent
paints, so the transition classes could not hold the control inert as they
claimed: the button took clicks while invisible. Gate the opt-in on the enter
transition settling and drop the declarations that never applied.

* fix: Measure the scrollbar gutter and keep the scroll button unreachable

The spacer assumed the gutter was the `::-webkit-scrollbar` width. Blink and
WebKit honour that rule, Firefox ignores it and sizes the band itself, and an
overlay scrollbar reserves nothing at all, so on those the composer and the
scroll-to-bottom control sat off the messages they are supposed to line up
with. Measure what the message column actually holds back and publish it for
the spacer to read, leaving the token as the pre-measurement fallback.

Gating the scroll button on pointer events alone also left it enabled, so it
kept its place in the tab order and answered Enter while invisible. Disable it
until the same gate opens, and hold its opacity so being briefly unreachable
does not dim it on top of the wrapper's own fade.
This commit is contained in:
Marco Beretta 2026-08-15 07:19:34 +02:00 committed by GitHub
parent 73812adca1
commit c4357fc9e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 600 additions and 146 deletions

View file

@ -139,6 +139,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
<div
className={cn(
'w-full',
!isLandingPage && 'scrollbar-gutter-spacer',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>

View file

@ -4,10 +4,9 @@ import type { ReactNode } from 'react';
/**
* Re-attributes response content to its author mid-message. A `SteerPart`
* renders a full user turn inside the response, so the parts that resume
* after it need the author's icon and label restated the message-level
* header only renders once, above the first part. Outdented past the icon
* column (like `SteerPart`) so it aligns with the top-level header; that
* column only exists from `md` up, so the outdent is gated to match.
* after it need the author's icon and label restated. The message-level
* header only renders once, above the first part. Sits on the same left
* edge as the message body.
*/
const AuthorHeader = memo(function AuthorHeader({
icon,
@ -17,16 +16,16 @@ const AuthorHeader = memo(function AuthorHeader({
label: string;
}) {
return (
<div
className="relative flex w-full gap-2 md:-ml-9 md:w-[calc(100%+2.25rem)] md:gap-3"
data-testid="author-header"
>
<div className="relative flex flex-shrink-0 flex-col items-center" aria-hidden="true">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{icon}
</div>
<div className="relative flex min-h-7 w-full items-center gap-2" data-testid="author-header">
<div
className="flex size-6 flex-shrink-0 items-center justify-center overflow-hidden rounded-full"
aria-hidden="true"
>
{icon}
</div>
<h2 className="select-none text-sm font-semibold text-text-primary">{label}</h2>
<h2 className="min-w-0 select-none truncate text-sm font-semibold text-text-primary">
{label}
</h2>
</div>
);
});

View file

@ -75,7 +75,7 @@ const SteerPart = memo(function SteerPart({
return (
<div
id={steerId ? `steer-${steerId}` : undefined}
className="steer-render group relative my-5 flex w-full justify-end md:-ml-9 md:w-[calc(100%+2.25rem)]"
className="steer-render group relative my-5 flex w-full justify-end"
data-testid="steer-part"
>
<div className="user-turn relative flex w-fit max-w-[90%] flex-col items-end sm:max-w-[85%]">

View file

@ -12,13 +12,13 @@ describe('AuthorHeader', () => {
expect(screen.getByRole('heading', { name: 'GitHub Agent' })).toBeVisible();
});
it('only outdents past the avatar gutter where that gutter exists', () => {
it('stays on the message content edge instead of outdenting', () => {
renderHeader();
const header = screen.getByTestId('author-header');
expect(header).toHaveClass('w-full', 'md:-ml-9', 'md:w-[calc(100%+2.25rem)]');
expect(header).not.toHaveClass('-ml-9');
expect(header).toHaveClass('w-full', 'items-center', 'gap-2');
expect(header).not.toHaveClass('md:-ml-9', '-ml-9');
});
it('keeps the icon out of the accessible name', () => {

View file

@ -142,11 +142,11 @@ describe('SteerPart presentation', () => {
expect(part).toHaveClass('steer-render');
});
it('only outdents past the avatar gutter where that gutter exists', () => {
it('stays on the message content edge instead of outdenting', () => {
renderPart();
const part = screen.getByTestId('steer-part');
expect(part).toHaveClass('w-full', 'md:-ml-9', 'md:w-[calc(100%+2.25rem)]');
expect(part).not.toHaveClass('-ml-9');
expect(part).toHaveClass('w-full');
expect(part).not.toHaveClass('md:-ml-9', '-ml-9');
});
it('renders steer attachments', () => {

View file

@ -35,7 +35,7 @@ function Message(props: TMessageProps) {
return (
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center px-4 py-3 md:px-6">
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<MessageRender {...props} isSubmitting={effectiveIsSubmitting} chatContext={chatContext} />
</div>
</MessageContainer>

View file

@ -10,6 +10,7 @@ import {
} from '~/utils';
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { revealOnRowHoverClasses, messageFooterClasses } from './styles';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
@ -101,11 +102,17 @@ function MessageParts(props: TMessageProps) {
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<div className="m-auto justify-center px-4 py-3 md:px-6">
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<MessageRow
id={messageId ?? ''}
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
label={name}
hoverLabel={getHeaderModelName(
agent?.model,
assistant?.model,
message.model,
conversation?.model,
)}
timestamp={message.createdAt ?? message.clientTimestamp}
ariaLabel={getMessageAriaLabel(message, localize)}
headerPrefix={getHeaderPrefixForScreenReader(message, localize)}

View file

@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import { useScreenshot, useMessageScrolling, useScrollbarGutter, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { steerOverlayHeightFamily } from '~/store/steer';
import { MessagesViewProvider } from '~/Providers';
@ -38,6 +38,7 @@ const ScrollButton = memo(function ScrollButton({
}) {
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [showScrollButton, setShowScrollButton] = useState(false);
const [isSettled, setIsSettled] = useState(false);
const scrollToBottomRef = useRef<HTMLDivElement>(null);
const timeoutIdRef = useRef<NodeJS.Timeout>();
@ -70,17 +71,20 @@ const ScrollButton = memo(function ScrollButton({
in={showScrollButton && scrollButtonPreference}
timeout={{
enter: 300,
exit: 250,
exit: 180,
}}
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
nodeRef={scrollToBottomRef}
onEntered={() => setIsSettled(true)}
onExit={() => setIsSettled(false)}
>
<ScrollToBottom
ref={scrollToBottomRef}
scrollHandler={scrollHandler}
overlayHeight={overlayHeight}
interactive={isSettled}
/>
</CSSTransition>
);
@ -106,6 +110,8 @@ function MessagesViewContent({
handleNearBottomChange,
} = useMessageScrolling(_messagesTree);
useScrollbarGutter(scrollableRef);
const { conversationId } = conversation ?? {};
/** The in-flight steer overlay floats above the composer over the bottom of

View file

@ -4,6 +4,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import Icon from '~/components/Chat/Messages/MessageIcon';
@ -110,11 +111,12 @@ function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
return (
<div className="w-full bg-transparent text-text-primary">
<div className="m-auto px-4 py-3 md:px-6">
<div className="m-auto px-4 py-3 sm:px-0">
<MessageRow
id={message.messageId}
icon={<Icon iconData={iconData} />}
label={messageLabel}
hoverLabel={getHeaderModelName(message.model)}
timestamp={message.createdAt ?? message.clientTimestamp}
ariaLabel={getMessageAriaLabel(message, localize)}
headerPrefix={getHeaderPrefixForScreenReader(message, localize)}

View file

@ -0,0 +1,68 @@
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type HeaderLabelProps = {
label: string;
hoverLabel?: string | null;
};
/** Agents and assistants are keyed by document id, and a message's `model`
* carries that id rather than a model name, so neither prefix may reach the
* header. */
const DOCUMENT_ID_PREFIXES = ['agent_', 'asst_'];
/** Skip document ids so the hover label is a real model name. */
export function getHeaderModelName(
...candidates: Array<string | null | undefined>
): string | undefined {
return candidates.find(
(value): value is string =>
value != null &&
value !== '' &&
!DOCUMENT_ID_PREFIXES.some((prefix) => value.startsWith(prefix)),
);
}
/** Both names occupy one grid cell so the slot is sized by the longer of the
* two and neither reflows the header as they cross over. */
const labelSlot =
'[grid-area:1/1] truncate transition-[opacity,transform,filter] [transition-duration:var(--resize-dur)] [transition-timing-function:var(--resize-ease)] motion-reduce:transition-none motion-reduce:transform-none motion-reduce:blur-none';
/** Provider name that crossfades to the model name. A pointer swaps it on the
* label itself; focusing anything in the message row swaps it too, so a
* sighted keyboard user reaches the model the same way they reach the
* timestamp. The model is additionally carried in text that never hides, for
* screen readers that never move the visual focus at all. */
export default function HeaderLabel({ label, hoverLabel }: HeaderLabelProps) {
const localize = useLocalize();
if (!hoverLabel || hoverLabel === label) {
return <span className="min-w-0 truncate">{label}</span>;
}
return (
<span className="group/label inline-grid min-w-0 max-w-full">
<span
className={cn(
labelSlot,
'group-hover/label:-translate-y-1 group-hover/label:opacity-0 group-hover/label:blur-[2px]',
'group-focus-within:-translate-y-1 group-focus-within:opacity-0 group-focus-within:blur-[2px]',
)}
>
{label}
</span>
<span
aria-hidden="true"
className={cn(
labelSlot,
'translate-y-1 opacity-0 blur-[2px]',
'group-hover/label:translate-y-0 group-hover/label:opacity-100 group-hover/label:blur-0',
'group-focus-within:translate-y-0 group-focus-within:opacity-100 group-focus-within:blur-0',
)}
>
{hoverLabel}
</span>
<span className="sr-only">{localize('com_ui_message_model', { 0: hoverLabel })}</span>
</span>
);
}

View file

@ -10,6 +10,7 @@ import {
} from '~/utils';
import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
@ -148,6 +149,12 @@ const MessageRender = memo(function MessageRender({
id={msg.messageId}
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
label={messageLabel ?? ''}
hoverLabel={getHeaderModelName(
agent?.model,
assistant?.model,
msg.model,
conversation?.model,
)}
timestamp={msg.createdAt ?? msg.clientTimestamp}
ariaLabel={getMessageAriaLabel(msg, localize)}
headerPrefix={getHeaderPrefixForScreenReader(msg, localize)}

View file

@ -1,10 +1,12 @@
import type { ReactNode } from 'react';
import MessageTimestamp from './MessageTimestamp';
import HeaderLabel from './HeaderLabel';
import { cn } from '~/utils';
type MessageRowProps = {
id?: string;
label: string;
hoverLabel?: string | null;
icon: ReactNode;
children: ReactNode;
footer: ReactNode;
@ -22,6 +24,7 @@ export default function MessageRow({
id,
icon,
label,
hoverLabel,
footer,
children,
timestamp,
@ -33,12 +36,13 @@ export default function MessageRow({
fullWidth = false,
isEditing = false,
}: MessageRowProps) {
const showAssistantHeader = !isCreatedByUser && !hasParallelContent;
let widthClass = 'w-full max-w-3xl';
// Same column as ChatForm: max-width plus `sm:px-2`, so the body lines
// up with the composer surface rather than the form's outer box.
let widthClass = 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl';
if (fullWidth) {
widthClass = 'w-full max-w-full';
widthClass = 'w-full max-w-full sm:px-2';
} else if (hasParallelContent) {
widthClass = 'w-full md:max-w-[58rem] xl:max-w-[70rem]';
widthClass = 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]';
}
return (
@ -58,7 +62,6 @@ export default function MessageRow({
className={cn(
'relative flex min-w-0 flex-col',
isCreatedByUser ? 'user-turn' : 'agent-turn',
showAssistantHeader && 'md:pl-9',
(hasParallelContent || isEditing) && 'w-full',
!hasParallelContent &&
isCreatedByUser &&
@ -74,16 +77,16 @@ export default function MessageRow({
<MessageTimestamp value={timestamp} />
</h2>
) : (
<h2 className="flex min-h-7 select-none items-center text-sm font-semibold text-text-primary">
<h2 className="flex min-h-7 w-full select-none items-center gap-2 text-sm font-semibold text-text-primary">
<span
aria-hidden="true"
className="mr-2 flex size-6 flex-shrink-0 items-center justify-center overflow-hidden rounded-full md:absolute md:left-0 md:top-0.5 md:mr-0"
className="flex size-6 flex-shrink-0 items-center justify-center overflow-hidden rounded-full"
>
{icon}
</span>
<span className="sr-only">{headerPrefix}</span>
{label}
<MessageTimestamp value={timestamp} />
<HeaderLabel label={label} hoverLabel={hoverLabel} />
<MessageTimestamp value={timestamp} className="ml-auto shrink-0 font-normal" />
</h2>
))}

View file

@ -1,15 +1,27 @@
import { useTranslation } from 'react-i18next';
import { cn, getMessageTimestamp } from '~/utils';
import useTimeTick from '~/hooks/useTimeTick';
import { getMessageTimestamp } from '~/utils';
type Timestamp = NonNullable<ReturnType<typeof getMessageTimestamp>>;
function TimestampText({ timestamp }: { timestamp: Timestamp }) {
function TimestampText({
timestamp,
className,
revealOnHover = true,
}: {
timestamp: Timestamp;
className?: string;
revealOnHover?: boolean;
}) {
return (
<time
dateTime={timestamp.iso}
title={timestamp.isRecent ? timestamp.absolute : undefined}
className="ml-2 text-xs font-normal text-text-secondary transition-opacity duration-200 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
className={cn(
'message-timestamp text-xs font-normal text-text-secondary',
revealOnHover && 'ml-2 transition-opacity duration-200',
className,
)}
>
{timestamp.isRecent ? timestamp.relative : timestamp.absolute}
</time>
@ -18,7 +30,17 @@ function TimestampText({ timestamp }: { timestamp: Timestamp }) {
/** Only recent timestamps subscribe to the shared minute ticker, so the
* per-minute sweep re-renders a handful of rows instead of every message. */
function RecentTimestamp({ value, language }: { value?: string | null; language: string }) {
function RecentTimestamp({
value,
language,
className,
revealOnHover,
}: {
value?: string | null;
language: string;
className?: string;
revealOnHover?: boolean;
}) {
useTimeTick();
const timestamp = getMessageTimestamp(value, language);
@ -26,7 +48,9 @@ function RecentTimestamp({ value, language }: { value?: string | null; language:
return null;
}
return <TimestampText timestamp={timestamp} />;
return (
<TimestampText timestamp={timestamp} className={className} revealOnHover={revealOnHover} />
);
}
/**
@ -36,7 +60,15 @@ function RecentTimestamp({ value, language }: { value?: string | null; language:
* ("10 minutes ago") with the absolute date on hover; older messages show the
* absolute date directly.
*/
export default function MessageTimestamp({ value }: { value?: string | null }) {
export default function MessageTimestamp({
value,
className,
revealOnHover,
}: {
value?: string | null;
className?: string;
revealOnHover?: boolean;
}) {
const { i18n } = useTranslation();
const timestamp = getMessageTimestamp(value, i18n.language);
@ -45,8 +77,17 @@ export default function MessageTimestamp({ value }: { value?: string | null }) {
}
if (timestamp.isRecent) {
return <RecentTimestamp value={value} language={i18n.language} />;
return (
<RecentTimestamp
value={value}
language={i18n.language}
className={className}
revealOnHover={revealOnHover}
/>
);
}
return <TimestampText timestamp={timestamp} />;
return (
<TimestampText timestamp={timestamp} className={className} revealOnHover={revealOnHover} />
);
}

View file

@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import HeaderLabel, { getHeaderModelName } from '../HeaderLabel';
describe('getHeaderModelName', () => {
it('prefers a real model over an agent document id', () => {
expect(getHeaderModelName('agent_abc', 'gemma4:12b-it-qat')).toBe('gemma4:12b-it-qat');
});
it('returns nothing when only an agent id is available', () => {
expect(getHeaderModelName('agent_abc')).toBeUndefined();
});
/* An Assistants-endpoint message keys the assistant map by `assistant.id`, so
its `model` field holds an `asst_` id rather than a model name. */
it('prefers a real model over an assistant document id', () => {
expect(getHeaderModelName(undefined, 'gpt-4o', 'asst_abc')).toBe('gpt-4o');
});
it('returns nothing when only an assistant id is available', () => {
expect(getHeaderModelName(undefined, undefined, 'asst_abc')).toBeUndefined();
});
});
describe('HeaderLabel', () => {
it('renders only the provider when there is no model to show', () => {
render(<HeaderLabel label="Ollama" />);
expect(screen.getByText('Ollama')).toBeVisible();
expect(screen.queryByText('gemma4:12b-it-qat')).not.toBeInTheDocument();
});
it('keeps the model in the slot for a hover swap', () => {
render(<HeaderLabel label="Ollama" hoverLabel="gemma4:12b-it-qat" />);
expect(screen.getByText('Ollama')).toBeVisible();
expect(screen.getByText('gemma4:12b-it-qat')).toHaveAttribute('aria-hidden', 'true');
});
/* A pointer swap alone would strand a sighted keyboard user, who reaches the
row by focus. `.message-render` carries the `group` this keys off. */
it('also swaps the model in when the message row takes focus', () => {
render(<HeaderLabel label="Ollama" hoverLabel="gemma4:12b-it-qat" />);
expect(screen.getByText('Ollama')).toHaveClass('group-focus-within:opacity-0');
expect(screen.getByText('gemma4:12b-it-qat')).toHaveClass('group-focus-within:opacity-100');
});
/* Screen readers never move the visual focus, so the model also has to reach
assistive tech through text that is never hidden. */
it('names the model in text that does not depend on hover', () => {
render(<HeaderLabel label="Ollama" hoverLabel="gemma4:12b-it-qat" />);
const announced = screen.getByText('Model: gemma4:12b-it-qat');
expect(announced).toHaveClass('sr-only');
expect(announced).not.toHaveAttribute('aria-hidden');
});
});

View file

@ -3,7 +3,9 @@ import MessageRow from '../MessageRow';
jest.mock('../MessageTimestamp', () => ({
__esModule: true,
default: () => <span data-testid="message-timestamp" />,
default: ({ className }: { className?: string }) => (
<span data-testid="message-timestamp" className={className} />
),
}));
const MESSAGE_BODY = 'Message body';
@ -23,6 +25,7 @@ const renderRow = ({
<MessageRow
id="message-1"
label={isCreatedByUser ? 'You' : 'Assistant'}
hoverLabel={isCreatedByUser ? undefined : 'gpt-5.6'}
icon={<span data-testid="message-icon" />}
footer={<div data-testid="message-actions" />}
ariaLabel={isCreatedByUser ? 'User message' : 'Assistant message'}
@ -68,26 +71,52 @@ describe('MessageRow', () => {
const avatar = screen.getByTestId('message-icon').parentElement;
/** The accessible name resolves only when `aria-hidden` excludes the avatar. */
expect(screen.getByRole('heading', { name: 'Message from Assistant' })).toContainElement(
screen.getByTestId('message-icon'),
);
expect(
screen.getByRole('heading', { name: 'Message from Assistant Model: gpt-5.6' }),
).toContainElement(screen.getByTestId('message-icon'));
expect(avatar).toHaveAttribute('aria-hidden', 'true');
expect(avatar).toHaveClass('size-6', 'md:absolute', 'md:left-0', 'md:top-0.5');
expect(avatar).toHaveClass('size-6');
expect(avatar).not.toHaveClass('md:absolute', 'md:left-0');
});
it('reserves the avatar gutter on desktop only so mobile content starts flush left', () => {
it('keeps the icon and provider name on the message content edge', () => {
renderRow({ isCreatedByUser: false });
const row = screen.getByLabelText('Assistant message');
const agentTurn = row.querySelector('.agent-turn');
expect(agentTurn).toHaveClass('md:pl-9');
expect(agentTurn).not.toHaveClass('pl-9');
expect(agentTurn).not.toHaveClass('md:pl-9', 'pl-9');
expect(row).not.toHaveClass('gap-3');
expect(row.children).toHaveLength(1);
expect(screen.getAllByTestId('message-icon')).toHaveLength(1);
});
it('puts icon, name, and datetime on one bar across the message column', () => {
renderRow({ isCreatedByUser: false });
const heading = screen.getByRole('heading', { name: 'Message from Assistant Model: gpt-5.6' });
expect(heading).toHaveClass('w-full', 'gap-2');
expect(screen.getByTestId('message-timestamp')).toHaveClass('ml-auto');
});
it('keeps the model name ready to replace the provider on hover', () => {
renderRow({ isCreatedByUser: false });
expect(screen.getByText('Assistant')).toBeVisible();
expect(screen.getByText('gpt-5.6')).toHaveAttribute('aria-hidden', 'true');
});
/* The crossfade is pointer-only, so the header bar has to name the model in
the heading itself rather than leave it behind a hover. */
it('names the model in the heading for assistive technology', () => {
renderRow({ isCreatedByUser: false });
expect(
screen.getByRole('heading', { name: 'Message from Assistant Model: gpt-5.6' }),
).toBeVisible();
});
it('preserves the assistant turn marker for parallel content', () => {
renderRow({ isCreatedByUser: false, hasParallelContent: true });
@ -115,10 +144,21 @@ describe('MessageRow', () => {
const row = screen.getByLabelText('Assistant message');
const messageSurface = screen.getByTestId('message-body');
expect(row.querySelector('.agent-turn')).toHaveClass('w-full', 'md:pl-9');
expect(row.querySelector('.agent-turn')).toHaveClass('w-full');
expect(row.querySelector('.agent-turn')).not.toHaveClass('md:pl-9');
expect(messageSurface).toHaveClass('w-full');
});
it('matches the chat form reading width', () => {
renderRow({ isCreatedByUser: false });
expect(screen.getByLabelText('Assistant message')).toHaveClass(
'sm:px-2',
'md:max-w-3xl',
'xl:max-w-4xl',
);
});
it('allows the maximized preference to use the full conversation width', () => {
renderRow({ isCreatedByUser: false, fullWidth: true });

View file

@ -110,3 +110,24 @@ describe('ChatView page heading', () => {
).not.toBeInTheDocument();
});
});
describe('ChatView composer column', () => {
beforeEach(() => {
mockParams.mockReturnValue({ conversationId: 'convo-1' });
mockConversation.mockReturnValue({ conversationId: 'convo-1', title: 'Deploy checklist' });
});
/* The composer's in-flight steer overlay is painted above the composer's top
edge, so a scroll container here would clip it out of sight for the whole
run. The gutter that lines the column up with the messages has to be
reserved with padding instead. */
test('reserves the message column gutter without becoming a scroll container', () => {
const { container } = render(<ChatView />);
const composerColumn = container.querySelector('.scrollbar-gutter-spacer');
expect(composerColumn).not.toBeNull();
expect(composerColumn).not.toHaveClass('overflow-y-auto');
expect(composerColumn).not.toHaveClass('scrollbar-gutter-stable');
});
});

View file

@ -11,6 +11,7 @@ import {
import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
@ -150,6 +151,12 @@ const ContentRender = memo(function ContentRender({
id={msg.messageId}
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
label={messageLabel ?? ''}
hoverLabel={getHeaderModelName(
agent?.model,
assistant?.model,
msg.model,
conversation?.model,
)}
timestamp={msg.createdAt ?? msg.clientTimestamp}
ariaLabel={getMessageAriaLabel(msg, localize)}
headerPrefix={getHeaderPrefixForScreenReader(msg, localize)}

View file

@ -35,7 +35,7 @@ function MessageContent(props: TMessageProps) {
return (
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center px-4 py-3 md:px-6">
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<ContentRender {...props} isSubmitting={effectiveIsSubmitting} chatContext={chatContext} />
</div>
</MessageContainer>

View file

@ -1,6 +1,7 @@
import { forwardRef } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDown } from 'lucide-react';
import { Button } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -12,29 +13,53 @@ type Props = {
* composer into this same corner. Lifts the button clear of it.
*/
overlayHeight?: number;
/**
* True once the enter transition has settled. The wrapper spans the column
* and stays inert so it never swallows clicks meant for the thread, which
* leaves the button to opt back in; gate that opt-in, because a descendant
* that opts in is hit-testable even while its parent fades, so a fading or
* not-yet-visible button would still take the click. Pointers are only half
* of it: until this is true the control is also disabled, since an enabled
* button stays in the tab order and answers Enter however it paints.
*/
interactive?: boolean;
};
const ScrollToBottom = forwardRef<HTMLDivElement, Props>(
({ scrollHandler, overlayHeight = 0 }, ref) => {
({ scrollHandler, overlayHeight = 0, interactive = false }, ref) => {
const localize = useLocalize();
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
return (
<div
ref={ref}
className={cn(
'pointer-events-none absolute left-0 right-0 mx-auto flex justify-end px-4 md:px-0',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
)}
className="scrollbar-gutter-spacer pointer-events-none absolute inset-x-0 z-10"
style={{ bottom: `calc(1.25rem + ${overlayHeight}px)` }}
>
<button
onClick={scrollHandler}
className="premium-scroll-button pointer-events-auto cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
aria-label={localize('com_ui_scroll_to_bottom')}
<div
className={cn(
'mx-auto flex justify-end px-4 sm:px-2',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
)}
>
<ChevronDown className="h-4 w-4 text-text-secondary" />
</button>
<Button
type="button"
variant="outline"
size="icon"
onClick={scrollHandler}
disabled={!interactive}
aria-label={localize('com_ui_scroll_to_bottom')}
className={cn(
'rounded-full bg-surface-chat/90 text-text-primary active:scale-[0.96] motion-reduce:active:scale-100',
/* The wrapper owns the fade, so being briefly unreachable must not
dim the control on its way in on top of it. */
'disabled:opacity-100',
interactive ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<ChevronDown className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
);
},

View file

@ -1,49 +1,118 @@
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
jest.mock('recoil', () => ({
useRecoilValue: () => false,
}));
import ScrollToBottom from '../ScrollToBottom';
import store from '~/store';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/store', () => ({
__esModule: true,
default: { maximizeChatSpace: 'maximizeChatSpace' },
}));
import ScrollToBottom from '../ScrollToBottom';
const renderButton = (overlayHeight?: number) =>
render(<ScrollToBottom scrollHandler={jest.fn()} overlayHeight={overlayHeight} />);
const container = () => screen.getByLabelText('com_ui_scroll_to_bottom').parentElement;
const renderButton = ({
maximizeChatSpace = false,
overlayHeight,
interactive,
}: {
maximizeChatSpace?: boolean;
overlayHeight?: number;
interactive?: boolean;
} = {}) =>
render(
<RecoilRoot
initializeState={({ set }) => {
set(store.maximizeChatSpace, maximizeChatSpace);
}}
>
<ScrollToBottom
scrollHandler={jest.fn()}
overlayHeight={overlayHeight}
interactive={interactive}
/>
</RecoilRoot>,
);
describe('ScrollToBottom', () => {
it('rests just above the composer when nothing is queued', () => {
it('sits in the same padded column as the composer', () => {
const { container } = renderButton();
const column = container.querySelector('.sm\\:px-2');
expect(column).toHaveClass('px-4', 'md:max-w-3xl', 'xl:max-w-4xl');
expect(container.firstChild).toHaveClass('scrollbar-gutter-spacer');
});
/* The gutter has to be reserved with padding rather than by reserving a real
one: a scroll container clips, and the button's focus ring is painted
outside its box. */
it('reserves the gutter without becoming a scroll container', () => {
const { container } = renderButton();
expect(container.firstChild).not.toHaveClass('overflow-y-auto');
expect(container.firstChild).not.toHaveClass('scrollbar-gutter-stable');
});
/* The wrapper is inert so it never swallows clicks meant for the thread, and
the button opts back in. A descendant that opts in stays hit-testable while
its parent fades, so the opt-in waits for the enter transition to settle. */
it('does not take clicks until the enter transition has settled', () => {
renderButton();
expect(container()).toHaveStyle({ bottom: 'calc(1.25rem + 0px)' });
expect(screen.getByRole('button')).toHaveClass('pointer-events-none');
});
it('takes clicks once settled', () => {
renderButton({ interactive: true });
expect(screen.getByRole('button')).toHaveClass('pointer-events-auto');
});
/* Pointer events say nothing about the keyboard: an enabled button keeps its
place in the tab order and answers Enter however faint it is painted. */
it('stays out of reach of the keyboard until settled', () => {
renderButton();
expect(screen.getByRole('button')).toBeDisabled();
});
it('answers the keyboard once settled', () => {
renderButton({ interactive: true });
expect(screen.getByRole('button')).toBeEnabled();
});
/* The wrapper already fades the control in, so the disabled state must not
dim it a second time on the way. */
it('does not dim while it is unreachable', () => {
renderButton();
expect(screen.getByRole('button')).toHaveClass('disabled:opacity-100');
expect(screen.getByRole('button')).not.toHaveClass('disabled:opacity-50');
});
it('rests just above the composer when nothing is queued', () => {
const { container } = renderButton();
expect(container.firstChild).toHaveStyle({ bottom: 'calc(1.25rem + 0px)' });
});
it('lifts clear of the in-flight steer overlay', () => {
renderButton(96);
const { container } = renderButton({ overlayHeight: 96 });
expect(container()).toHaveStyle({ bottom: 'calc(1.25rem + 96px)' });
expect(container.firstChild).toHaveStyle({ bottom: 'calc(1.25rem + 96px)' });
});
it('stays inside the message column on mobile', () => {
it('names the control for assistive tech', () => {
renderButton();
/** Without a mobile gutter the button escapes to the viewport edge while
* the steer bubbles inset, so the two visibly disagree. */
expect(container()).toHaveClass('px-4', 'md:px-0');
expect(screen.getByRole('button', { name: 'com_ui_scroll_to_bottom' })).toHaveAttribute(
'type',
'button',
);
});
it('keeps the desktop width constraint', () => {
renderButton();
it('uses the full conversation width when the preference is on', () => {
const { container } = renderButton({ maximizeChatSpace: true });
const column = container.querySelector('.sm\\:px-2');
expect(container()).toHaveClass('md:max-w-3xl', 'xl:max-w-4xl');
expect(column).toHaveClass('max-w-full');
expect(column).not.toHaveClass('md:max-w-3xl');
});
});

View file

@ -2,6 +2,7 @@ import type { TMessageProps } from '~/common';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
@ -51,11 +52,12 @@ export default function Message(props: TMessageProps) {
return (
<>
<div className="w-full border-0 bg-transparent text-text-primary">
<div className="m-auto justify-center px-4 py-3 md:px-6">
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<MessageRow
id={messageId}
icon={<Icon message={message} conversation={conversation} />}
label={messageLabel}
hoverLabel={getHeaderModelName(message.model)}
timestamp={message.createdAt ?? message.clientTimestamp}
ariaLabel={getMessageAriaLabel(message, localize)}
headerPrefix={getHeaderPrefixForScreenReader(message, localize)}

View file

@ -0,0 +1,46 @@
import { renderHook } from '@testing-library/react';
import useScrollbarGutter, { SCROLLBAR_GUTTER_PROPERTY } from '../useScrollbarGutter';
/** jsdom reports 0 for both widths, so the band has to be posed by hand. */
const scrollContainer = ({ offsetWidth, clientWidth }: Record<string, number>) => {
const element = document.createElement('div');
Object.defineProperty(element, 'offsetWidth', { value: offsetWidth, configurable: true });
Object.defineProperty(element, 'clientWidth', { value: clientWidth, configurable: true });
return { current: element };
};
describe('useScrollbarGutter', () => {
afterEach(() => {
document.documentElement.style.removeProperty(SCROLLBAR_GUTTER_PROPERTY);
});
it('publishes the band the container actually holds back', () => {
renderHook(() => useScrollbarGutter(scrollContainer({ offsetWidth: 800, clientWidth: 785 })));
expect(document.documentElement.style.getPropertyValue(SCROLLBAR_GUTTER_PROPERTY)).toBe('15px');
});
/* An overlay scrollbar reserves nothing, so the column it aligns to must not
be pushed in by the WebKit token either. */
it('publishes zero when the scrollbar is an overlay', () => {
renderHook(() => useScrollbarGutter(scrollContainer({ offsetWidth: 800, clientWidth: 800 })));
expect(document.documentElement.style.getPropertyValue(SCROLLBAR_GUTTER_PROPERTY)).toBe('0px');
});
it('leaves the measurement in place for other threads when one unmounts', () => {
const { unmount } = renderHook(() =>
useScrollbarGutter(scrollContainer({ offsetWidth: 800, clientWidth: 785 })),
);
unmount();
expect(document.documentElement.style.getPropertyValue(SCROLLBAR_GUTTER_PROPERTY)).toBe('15px');
});
it('does nothing before the container mounts', () => {
renderHook(() => useScrollbarGutter({ current: null }));
expect(document.documentElement.style.getPropertyValue(SCROLLBAR_GUTTER_PROPERTY)).toBe('');
});
});

View file

@ -19,4 +19,5 @@ export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useCopyToClipboard } from './useCopyToClipboard';
export { default as useContentMetadata } from './useContentMetadata';
export { default as useMessageScrolling } from './useMessageScrolling';
export { default as useScrollbarGutter } from './useScrollbarGutter';
export { default as useSmoothStreaming } from './useSmoothStreaming';

View file

@ -0,0 +1,41 @@
import { useEffect } from 'react';
export const SCROLLBAR_GUTTER_PROPERTY = '--message-scrollbar-gutter';
/** Publishes the band the message column actually holds back for its scrollbar,
* so anything centred against that column reserves the same width instead of
* assuming one. `scrollbar-gutter: stable` reserves whatever the browser's own
* scrollbar measures, and the app only pins that down through
* `::-webkit-scrollbar`, which Blink and WebKit honour but Firefox ignores; an
* overlay scrollbar reserves nothing at all. Assuming the token there shifts
* the composer and the scroll-to-bottom control off the messages they align to.
*
* The value is app-wide, so it lives on the document element and outlives any
* one thread: a second chat column measures the same band, and dropping the
* property when one unmounts would strand the other on the fallback. */
export default function useScrollbarGutter(
scrollableRef: React.RefObject<HTMLDivElement | null>,
): void {
useEffect(() => {
const element = scrollableRef.current;
if (!element) {
return;
}
const publish = () => {
const gutter = Math.max(0, element.offsetWidth - element.clientWidth);
document.documentElement.style.setProperty(SCROLLBAR_GUTTER_PROPERTY, `${gutter}px`);
};
publish();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(publish);
observer.observe(element);
return () => observer.disconnect();
}, [scrollableRef]);
}

View file

@ -1532,6 +1532,7 @@
"com_ui_mermaid_exporting_png": "Creating PNG...",
"com_ui_mermaid_failed": "Failed to render diagram:",
"com_ui_message_input": "Message input",
"com_ui_message_model": "Model: {{0}}",
"com_ui_message_nav": "Message navigation",
"com_ui_message_nav_go_to_assistant": "Go to assistant message: {{0}}",
"com_ui_message_nav_go_to_user": "Go to user message: {{0}}",

View file

@ -81,6 +81,20 @@
scrollbar-gutter: stable;
}
/* Reserves the same band a `scrollbar-gutter: stable` container holds back, so
a centered column lines up with the scrolling message column. Use this, not
the gutter itself, wherever the column has content painted outside its box:
`scrollbar-gutter` only applies to scroll containers, and a scroll container
clips (the composer's in-flight steer overlay, a button's focus ring).
`useScrollbarGutter` measures the band the message column really reserves and
publishes it here. The token is only the fallback for before that first
measurement: it matches the `::-webkit-scrollbar` width, which Firefox
ignores and an overlay scrollbar drops to nothing. */
.scrollbar-gutter-spacer {
padding-inline-end: var(--message-scrollbar-gutter, var(--scrollbar-size));
}
/* Base wrapper for both preview and editor */
.sp-wrapper {
@apply flex h-full w-full grow flex-col;

View file

@ -581,85 +581,71 @@ pre {
margin: 0;
}
/* Scroll-to-bottom button — enter */
/* Show the message time when the row is hovered. Tailwind group-hover
loses to `[@media(hover:hover)]:opacity-0` (same specificity, :where()). */
@media (hover: hover) {
.message-render .message-timestamp {
opacity: 0;
}
.message-render:hover .message-timestamp,
.message-render:focus-within .message-timestamp {
opacity: 1;
}
}
/* Scroll-to-bottom: fade and rise on the same path. No scale. */
/* Inertness during the transition is owned by the button's `interactive` prop,
not by these classes: the wrapper is already inert, and a descendant that
opts back into pointer events stays hit-testable however its parent paints. */
.scroll-animation-enter {
opacity: 0;
transform: translateY(12px) scale(0.9);
pointer-events: none;
transform: translateY(8px);
}
.scroll-animation-enter-active {
animation: scroll-btn-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 1;
transform: translateY(0);
transition:
opacity var(--resize-dur) var(--resize-ease),
transform var(--resize-dur) var(--resize-ease);
}
.scroll-animation-enter-done {
opacity: 1;
transform: translateY(0) scale(1);
transform: translateY(0);
}
@keyframes scroll-btn-enter {
0% {
opacity: 0;
transform: translateY(12px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
.scroll-animation-exit {
opacity: 1;
transform: translateY(0);
}
/* Scroll-to-bottom button — exit */
.scroll-animation-exit-active {
animation: scroll-btn-exit 0.25s cubic-bezier(0.4, 0, 1, 1) forwards;
pointer-events: none;
opacity: 0;
transform: translateY(8px);
transition:
opacity 180ms cubic-bezier(0.4, 0, 1, 1),
transform 180ms cubic-bezier(0.4, 0, 1, 1);
}
.scroll-animation-exit-done {
display: none;
}
@keyframes scroll-btn-exit {
0% {
opacity: 1;
transform: translateY(0) scale(1);
@media (prefers-reduced-motion: reduce) {
.scroll-animation-enter,
.scroll-animation-exit,
.scroll-animation-exit-active {
transform: none;
}
100% {
opacity: 0;
transform: translateY(8px) scale(0.95);
.scroll-animation-enter-active,
.scroll-animation-exit-active {
transition: opacity 150ms ease;
}
}
/* Scroll-to-bottom button */
.premium-scroll-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
background-color: #ffffff;
z-index: 10;
overflow: hidden;
transition: transform 100ms ease;
}
.dark .premium-scroll-button {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
background-color: #2a2a2e;
}
.scroll-animation-enter-active .premium-scroll-button {
pointer-events: none !important;
}
.premium-scroll-button:active {
transform: scale(0.95);
}
.prose {
color: var(--tw-prose-body);
max-width: 65ch;
@ -1278,9 +1264,16 @@ button {
}
/* Webkit scrollbar */
:root {
/* Width of the app's own scrollbars, and so of the band a
`scrollbar-gutter: stable` container holds back. `.scrollbar-gutter-spacer`
reserves the same band without becoming a scroll container. */
--scrollbar-size: 0.5rem;
}
::-webkit-scrollbar {
height: 0.1em;
width: 0.5rem;
width: var(--scrollbar-size);
}
::-webkit-scrollbar-thumb {
@ -2122,6 +2115,8 @@ html {
.message-editor-text {
font-size: var(--markdown-font-size, var(--font-size-base));
line-height: 1.6;
/* `.prose` sets max-width: 65ch; fill the chat-form column instead. */
max-width: none;
}
.message-content pre code {