📱 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:
Danny Avila 2026-08-14 11:57:58 -04:00 committed by GitHub
parent 5d3edeb383
commit 69ce4b7b00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 78 additions and 16 deletions

View file

@ -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}

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 -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%]">

View file

@ -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();
});
});

View file

@ -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' },

View file

@ -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} />

View file

@ -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');
});