📏 style: Center Every Row Glyph on the Avatar's Axis (#15643)

* 📏 style: Center Every Row Glyph on the Avatar's Axis

Two rails were in use under a message header. `AgentHandoff` and `AgentUpdate`
gave their glyph a 24px slot — the width of the header avatar — so the glyph
centered on the avatar's axis and the text started where the name does. Every
other row used a 16px (thinking: 18px) slot: tool cards through `ProgressText`,
`ToolCallGroup` headers, the phase card's check, and the streaming cursor a
folded card renders beneath itself. Measured in Chromium against the avatar's
left edge: avatar center 12, those glyphs 8–9, and their text at 24–26 against
the name's 32. The cursor was also 5px low, because `.result-thinking` hangs
its dot 11px above a text line's box, which is right for the streaming
markdown cursor and wrong for a row.

`ROW_GLYPH_SLOT` in `rows.ts` is the one definition: a 24px, centered slot
(`min-w` rather than `w`, so a stacked icon strip can run wider). It is taken
by `ProgressText`'s icon, both `ToolCallGroup` glyph branches, the phase
check, the thinking button, and the cursor; `AgentHandoff`'s gap drops from
2.5 to 2 so its text meets the name too. The cursor stops borrowing
`EmptyText` and draws the dot through `.result-thinking-inline`, a variant
that puts the same pseudo-element in flow so the slot can center it.

After: every glyph at 12, every text edge at 32, the dot centered in its row.

* 📏 fix: Keep the Glyph Slot Inside the Row and the Dot Override Local

Review follow-ups on the row rail.

- The slot was 24px tall inside a 20px row. `ProgressText` positions its
  content absolutely at the row's top, so a taller slot could not be centered
  by the row and carried every call card's glyph and label 2px low. The slot
  keeps its 24px width and takes the row's 20px height.
- The pending skill row rendered its icon straight into the gap; the real
  `SkillCall` that replaces it in place used the slot, so the swap moved the
  glyph 4px and the text 8px. Both rows share the slot now.
- The cursor's in-flow dot no longer needs a global selector: `after:!static`
  on the element puts the one pseudo-element back in flow, and the `!` is what
  outranks the dot rule's three-class selector. The thinking button takes the
  shared slot instead of its own 24px square.
- A `span` wrapped `StackedToolIcons`, which renders a `div` for a strip; the
  wrapper is a `div` now.

* 📏 fix: Give ThinkingLabel the Shared Glyph Slot

`ThinkingLabel` exists to keep the reasoning-unavailable row identical to the
`ThinkingButton` row it stands in for, and the previous commit moved only the
button onto the shared slot — a detached subagent projection would have kept
its glyph at 9px and its text at 24px next to rows at 12px and 32px.

* 📏 fix: Put the Image-Gen and Web-Search Glyphs in the Shared Slot

`Part.tsx` routes image generation and web search to renderers that draw
their own glyph instead of going through `ProgressText`, so the slot the
previous commits gave every other row skipped them: their 16px icons and the
favicon stack still sat on the old axis, and a labeled phase containing either
call would have shifted sideways as it folded into the updated header. Both
rows take `ROW_GLYPH_SLOT`; the web-search streaming row's gap drops from 2.5
to 2 so its text meets the name's edge like the rest.
This commit is contained in:
Danny Avila 2026-09-06 11:31:35 -04:00 • committed by GitHub
parent b3b8986752
commit d812e2f98f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 96 additions and 47 deletions

View file

@ -12,8 +12,8 @@ import {
} from '~/hooks';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { getActivityLabelText } from '~/utils/activityLabels';
import { AttachmentGroup, EmptyText } from './Parts';
import { TOOL_ROW_CLASSES } from './rows';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from './rows';
import { AttachmentGroup } from './Parts';
import { cn } from '~/utils';
/** Matches `EXPAND_TRANSITION` so the panel and the label ticker resolve on
@ -57,18 +57,15 @@ function schedulePostPaint(callback: () => void): () => void {
}
/** The header's icon slot. Its only job is geometric: every other row in the
* transcript opens with a 16px glyph and an 8px gap, so a summary rendered
* without one sits 24px to the left of the rows it replaces — the fold then
* moves its own text sideways at the moment the reader is trying to follow
* it. */
* transcript opens with the same glyph slot and an 8px gap, so a summary
* rendered without one sits to the left of the rows it replaces — the fold
* then moves its own text sideways at the moment the reader is trying to
* follow it. */
function PhaseGlyph({ failed }: { failed: boolean }) {
const Icon = failed ? TriangleAlert : Check;
return (
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center',
failed ? 'text-text-warning' : 'text-text-secondary',
)}
className={cn(ROW_GLYPH_SLOT, failed ? 'text-text-warning' : 'text-text-secondary')}
aria-hidden="true"
>
<Icon size={14} />
@ -275,11 +272,17 @@ export default function ActivityPhaseGroup({
* starts and the cursor gives way to a row again. A cursor in a bare
* `Container` (20px, no margins) was 12px shorter than the tool row
* (`my-1.5 h-5`), so everything beneath the card stepped up on every
* absorb and back down on every call. The cursor takes the row's exact box
* and a 2px inset centers the 12px dot on the 16px glyph rail. */
* absorb and back down on every call. The cursor takes the row's exact box,
* and the dot sits in the row's glyph slot. `.result-thinking` draws the
* dot as an absolutely positioned pseudo-element 11px above its line —
* right for the streaming-markdown cursor, wrong here — so `after:!static`
* puts that one pseudo-element back in flow for the slot to center; the
* `!` is what outranks the dot rule's three-class selector. */
const cursor = showCursor ? (
<div className={cn(TOOL_ROW_CLASSES, 'ps-0.5')} data-testid="activity-phase-cursor">
<EmptyText />
<div className={TOOL_ROW_CLASSES} data-testid="activity-phase-cursor">
<span className={cn(ROW_GLYPH_SLOT, 'submitting')} aria-hidden="true">
<span className="result-thinking block after:!static" />
</span>
</div>
) : null;
const media =

View file

@ -50,7 +50,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
<button
type="button"
className={cn(
'tool-status-text flex appearance-none items-center gap-2.5 bg-transparent text-text-secondary',
'tool-status-text flex appearance-none items-center gap-2 bg-transparent text-text-secondary',
hasInfo
? 'transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none',

View file

@ -11,6 +11,7 @@ import { ToolIcon, isError } from '~/components/Chat/Messages/Content/ToolOutput
import Image from '~/components/Chat/Messages/Content/Image';
import { useProgress, useLocalize } from '~/hooks';
import { useToolCallIntent } from '../intent';
import { ROW_GLYPH_SLOT } from '../../rows';
import ProgressText from './ProgressText';
import { scaleImage } from '~/utils';
@ -259,7 +260,9 @@ export default function OpenAIImageGen({
})()}
</span>
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2">
<ToolIcon type="image_gen" isAnimating={isInProgress} />
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
<ToolIcon type="image_gen" isAnimating={isInProgress} />
</span>
<ProgressText
progress={progress}
error={reportsError}

View file

@ -1,5 +1,5 @@
import { ScrollText } from 'lucide-react';
import { TOOL_ROW_CLASSES } from '../rows';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from '../rows';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -41,10 +41,13 @@ export default function PendingSkillCall({
style={{ opacity: 1, transform: 'none' }}
>
<div className="inline-flex w-full items-center gap-2">
<ScrollText
className={cn('size-4 shrink-0 text-text-secondary', !loaded && 'animate-pulse')}
aria-hidden="true"
/>
{/** The real `SkillCall` replaces this row in place once the skill
* finalizes; sharing its glyph slot keeps that swap still. */}
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
<ScrollText
className={cn('size-4 shrink-0 text-text-secondary', !loaded && 'animate-pulse')}
/>
</span>
<span className={cn(!loaded && 'shimmer', 'min-w-0 truncate font-medium')}>{text}</span>
</div>
</div>

View file

@ -8,6 +8,7 @@ import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { fontSizeAtom } from '~/store/fontSize';
import { AnimatedText } from '../animate';
import { ROW_GLYPH_SLOT } from '../rows';
import { cn } from '~/utils';
/**
@ -86,7 +87,7 @@ export const ThinkingButton = memo(
fontSize,
)}
>
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
<span className={cn(ROW_GLYPH_SLOT, 'relative mr-2')}>
<Lightbulb
className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0"
aria-hidden="true"
@ -180,7 +181,7 @@ export const ThinkingLabel = memo(({ label, title }: { label: string; title?: st
className={cn('flex w-full items-center justify-start leading-[18px]', fontSize)}
title={title}
>
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
<span className={cn(ROW_GLYPH_SLOT, 'relative mr-2')}>
<Lightbulb className="icon-sm text-text-secondary" aria-hidden="true" />
</span>
<span className="min-w-0 truncate text-left text-text-secondary">{label}</span>

View file

@ -6,6 +6,7 @@ import { isReportableRunStepDuration } from 'librechat-data-provider';
import type { ToolCallPhase } from '~/utils/toolCallPhase';
import { cn, getRunStepDurationLabels } from '~/utils';
import CancelledIcon from './CancelledIcon';
import { ROW_GLYPH_SLOT } from './rows';
import { useLocalize } from '~/hooks';
const wrapperClass =
@ -113,7 +114,11 @@ export default function ProgressText({
onClick={hasInput ? onClick : undefined}
aria-expanded={hasInput ? isExpanded : undefined}
>
{icon}
{icon != null && (
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
{icon}
</span>
)}
<span className={cn(showShimmer ? 'shimmer' : '', 'min-w-0 truncate font-medium')}>
{text}
</span>

View file

@ -23,6 +23,7 @@ import { isBashProgrammaticToolCall } from './routing';
import { ASK_USER_QUESTION } from '~/utils/approval';
import { StackedToolIcons } from './ToolOutput';
import { AttachmentGroup } from './Parts';
import { ROW_GLYPH_SLOT } from './rows';
import store from '~/store';
interface ToolMeta {
@ -356,7 +357,8 @@ export default function ToolCallGroup({
* rather than "tools". */
<div
className={cn(
'flex size-4 shrink-0 items-center justify-center text-text-secondary',
ROW_GLYPH_SLOT,
'text-text-secondary',
!allCompleted && isSubmitting && 'animate-pulse text-text-primary',
)}
aria-hidden="true"
@ -364,12 +366,14 @@ export default function ToolCallGroup({
<CategoryIcon size={14} />
</div>
) : (
<StackedToolIcons
toolNames={iconToolNames}
mcpIconMap={mcpIconMap}
maxIcons={4}
isAnimating={!allCompleted && isSubmitting}
/>
<div className={ROW_GLYPH_SLOT} aria-hidden="true">
<StackedToolIcons
toolNames={iconToolNames}
mcpIconMap={mcpIconMap}
maxIcons={4}
isAnimating={!allCompleted && isSubmitting}
/>
</div>
)}
<span
className={cn(

View file

@ -13,6 +13,7 @@ import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import { StackedFavicons } from '~/components/Web/Sources';
import { useToolCallIntent } from './Parts/intent';
import { useSearchContext } from '~/Providers';
import { ROW_GLYPH_SLOT } from './rows';
import cn from '~/utils/cn';
import store from '~/store';
@ -257,11 +258,13 @@ export default function WebSearch({
: completedText
}
>
{hasSourceData ? (
<SourceFaviconStack sources={allSources} />
) : (
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
{hasSourceData ? (
<SourceFaviconStack sources={allSources} />
) : (
<Globe className="size-4 shrink-0 text-text-secondary" />
)}
</span>
<span className="min-w-0 truncate font-medium">{completedText}</span>
{hasSourceData && (
<ChevronDown
@ -309,12 +312,14 @@ export default function WebSearch({
}
return (
<div className="my-1 flex items-center gap-2.5">
<div className="my-1 flex items-center gap-2">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{genericProgressText}
</span>
{showSources && <StackedFavicons sources={streamingSources} start={-5} />}
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
{showSources && <StackedFavicons sources={streamingSources} start={-5} />}
<Globe className="size-4 shrink-0 text-text-secondary" />
</span>
<span className="tool-status-text shimmer min-w-0 truncate font-medium text-text-secondary">
{progressText}
</span>

View file

@ -1,8 +1,8 @@
import { ContentTypes } from 'librechat-data-provider';
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { TMessageContentParts } from 'librechat-data-provider';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from '../rows';
import ActivityPhaseGroup from '../ActivityPhaseGroup';
import { TOOL_ROW_CLASSES } from '../rows';
const mockUseSmoothStreaming = jest.fn(() => true);
const mockScheduleLayoutReconcile = jest.fn((_target: HTMLElement | null) => jest.fn());
@ -114,7 +114,14 @@ describe('ActivityPhaseGroup', () => {
for (const token of TOOL_ROW_CLASSES.split(' ')) {
expect(cursor).toHaveClass(token);
}
expect(cursor.querySelector('.result-thinking')).toBeInTheDocument();
/** The dot rides the same glyph slot as every row's icon, in flow, so the
* slot centers it on the avatar's axis instead of a text baseline. */
const slot = cursor.firstElementChild;
for (const token of ROW_GLYPH_SLOT.split(' ')) {
expect(slot).toHaveClass(token);
}
expect(slot).toHaveClass('submitting');
expect(cursor.querySelector('.result-thinking')).toHaveClass('after:!static');
});
test('opens the summary on the same glyph rail as the rows it replaces', () => {
@ -126,10 +133,12 @@ describe('ActivityPhaseGroup', () => {
const trigger = screen.getByRole('button', { name: LABEL });
expect(trigger).toHaveClass('justify-start', 'gap-2', 'p-0');
/** Leading glyph plus the chevron: a summary with no glyph slot sits 24px
* left of every tool row in the same list. */
/** Leading glyph plus the chevron, in the slot every row shares: a
* summary without it sits left of the rows it replaces. */
expect(trigger.querySelectorAll('svg')).toHaveLength(2);
expect(trigger.firstElementChild).toHaveClass('size-4', 'shrink-0');
for (const token of ROW_GLYPH_SLOT.split(' ')) {
expect(trigger.firstElementChild).toHaveClass(token);
}
expect(screen.getByText(LABEL).parentElement).toHaveClass('flex-1', 'text-left');
});

View file

@ -826,14 +826,18 @@ describe('ContentParts — synthesized activity folds', () => {
/** Providers append an empty TEXT slot after visible output. The cursor
* belongs to the card (the run's tail is inside its span), and that
* trailing slot looks like the initial waiting state from inside its own
* segment — so without the ownership signal both render one. */
renderContentParts({
* segment — so without the ownership signal both render one. The card
* draws its own in-flow dot; the segment's `EmptyText` must stay out. */
const { container } = renderContentParts({
...baseProps,
isSubmitting: true,
content: [...labeledRun(), makeTextPart('')],
});
expect(screen.getAllByTestId('empty-text')).toHaveLength(1);
const dots = container.querySelectorAll('.result-thinking');
expect(dots).toHaveLength(1);
expect(dots[0]).toHaveClass('after:!static');
expect(screen.queryAllByTestId('empty-text')).toHaveLength(0);
});
it('leaves an unlabeled run rendering exactly as before', () => {

View file

@ -6,3 +6,15 @@
* everything under the card moves on each swap.
*/
export const TOOL_ROW_CLASSES = 'relative my-1.5 flex h-5 shrink-0 items-center gap-2.5';
/**
* The leading glyph slot of a row: 24px wide, the width of the message
* header's avatar, and the row's own 20px tall. A 16px tool icon, the 14px
* phase check and the 12px cursor dot all center in it, so every row's glyph
* sits on the avatar's axis, and with the row's 8px gap every row's text
* starts where the header's name does. `min-w` rather than `w`, so a stacked
* icon strip can run wider without overlapping its label; never taller than
* `TOOL_ROW_CLASSES`, whose `ProgressText` content is absolutely positioned
* and would carry a taller slot 2px below the row's center.
*/
export const ROW_GLYPH_SLOT = 'flex h-5 min-w-6 shrink-0 items-center justify-center';