mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
📐 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:
parent
73812adca1
commit
c4357fc9e3
27 changed files with 600 additions and 146 deletions
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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%]">
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
68
client/src/components/Chat/Messages/ui/HeaderLabel.tsx
Normal file
68
client/src/components/Chat/Messages/ui/HeaderLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
41
client/src/hooks/Messages/useScrollbarGutter.ts
Normal file
41
client/src/hooks/Messages/useScrollbarGutter.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue