mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📱 style: Reclaim the Assistant Avatar Gutter on Mobile (#14836)
Assistant content sat 36px from the left edge on mobile (a 24px avatar column plus `gap-3`) against a 16px gutter on the right, costing ~13% of the reading width on every response. Move the avatar into the assistant heading and restore the gutter as `md:pl-9` on the content column, so the column only exists from `md` up. Geometry is unchanged on desktop: 768 - 24 - 12 and 768 - 36 both leave a 732px content box, and an absolutely positioned child resolves against the padding box, so `md:left-0` lands the avatar where the column started. `AuthorHeader` and `SteerPart` hard-coded `-ml-9` to reach back past that column; both are gated to `md` so they no longer outdent off-screen. Pure `md:` variants rather than `useMediaQuery`, which resolves desktop-first after paint and would reflow every row on mobile. Covers all five surfaces sharing `MessageRow`: the three message paths, the shared-conversation view, and search results.
This commit is contained in:
parent
5d3edeb383
commit
69ce4b7b00
6 changed files with 78 additions and 16 deletions
|
|
@ -6,7 +6,8 @@ import type { ReactNode } from 'react';
|
|||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const AuthorHeader = memo(function AuthorHeader({
|
||||
icon,
|
||||
|
|
@ -16,7 +17,10 @@ const AuthorHeader = memo(function AuthorHeader({
|
|||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative -ml-9 flex w-[calc(100%+2.25rem)] gap-3" data-testid="author-header">
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const SteerPart = memo(function SteerPart({
|
|||
return (
|
||||
<div
|
||||
id={steerId ? `steer-${steerId}` : undefined}
|
||||
className="steer-render group relative my-5 -ml-9 flex w-[calc(100%+2.25rem)] justify-end"
|
||||
className="steer-render group relative my-5 flex w-full justify-end md:-ml-9 md:w-[calc(100%+2.25rem)]"
|
||||
data-testid="steer-part"
|
||||
>
|
||||
<div className="user-turn relative flex w-fit max-w-[90%] flex-col items-end sm:max-w-[85%]">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import AuthorHeader from '../AuthorHeader';
|
||||
|
||||
const renderHeader = () =>
|
||||
render(<AuthorHeader icon={<span data-testid="author-icon" />} label="GitHub Agent" />);
|
||||
|
||||
describe('AuthorHeader', () => {
|
||||
it('restates the author with an icon and a heading', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('author-icon')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'GitHub Agent' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('only outdents past the avatar gutter where that gutter exists', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('keeps the icon out of the accessible name', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('author-icon').closest('[aria-hidden="true"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -142,6 +142,13 @@ describe('SteerPart presentation', () => {
|
|||
expect(part).toHaveClass('steer-render');
|
||||
});
|
||||
|
||||
it('only outdents past the avatar gutter where that gutter exists', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('renders steer attachments', () => {
|
||||
renderPart([
|
||||
{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' },
|
||||
|
|
|
|||
|
|
@ -49,26 +49,16 @@ export default function MessageRow({
|
|||
className={cn(
|
||||
'message-render group mx-auto flex min-w-0 flex-1 font-theme-ui transition-[max-width] duration-theme-normal motion-reduce:transition-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
|
||||
isCreatedByUser ? 'justify-end' : 'items-start gap-3',
|
||||
isCreatedByUser ? 'justify-end' : 'items-start',
|
||||
widthClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showAssistantHeader && (
|
||||
<div
|
||||
className="relative flex flex-shrink-0 flex-col items-center pt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center overflow-hidden rounded-full">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col',
|
||||
isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
showAssistantHeader && 'md:pl-9',
|
||||
(hasParallelContent || isEditing) && 'w-full',
|
||||
!hasParallelContent &&
|
||||
isCreatedByUser &&
|
||||
|
|
@ -85,6 +75,12 @@ export default function MessageRow({
|
|||
</h2>
|
||||
) : (
|
||||
<h2 className="flex min-h-7 select-none items-center 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"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sr-only">{headerPrefix}</span>
|
||||
{label}
|
||||
<MessageTimestamp value={timestamp} />
|
||||
|
|
|
|||
|
|
@ -62,6 +62,32 @@ describe('MessageRow', () => {
|
|||
expect(screen.getByRole('heading', { name: /Assistant/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it('carries the assistant avatar inside the heading without naming it', () => {
|
||||
renderRow({ isCreatedByUser: false });
|
||||
|
||||
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(avatar).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(avatar).toHaveClass('size-6', 'md:absolute', 'md:left-0', 'md:top-0.5');
|
||||
});
|
||||
|
||||
it('reserves the avatar gutter on desktop only so mobile content starts flush left', () => {
|
||||
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(row).not.toHaveClass('gap-3');
|
||||
expect(row.children).toHaveLength(1);
|
||||
expect(screen.getAllByTestId('message-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves the assistant turn marker for parallel content', () => {
|
||||
renderRow({ isCreatedByUser: false, hasParallelContent: true });
|
||||
|
||||
|
|
@ -89,7 +115,7 @@ describe('MessageRow', () => {
|
|||
const row = screen.getByLabelText('Assistant message');
|
||||
const messageSurface = screen.getByTestId('message-body');
|
||||
|
||||
expect(row.querySelector('.agent-turn')).toHaveClass('w-full');
|
||||
expect(row.querySelector('.agent-turn')).toHaveClass('w-full', 'md:pl-9');
|
||||
expect(messageSurface).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue