refactor(client): unify tool row disclosure styling in a shared module

Extract the hover-reveal chevron and expanded panel spacing into
disclosure.ts and align every tool activity row on the same layout:
h-5 header, my-1 rhythm, full-width click target, and a 200ms chevron
rotation. Rows rendered through ProgressText now stretch across the
message column (the absolute content layer was shrink-wrapping to the
text), and the copy buttons in thinking and output panels anchor to
the row instead of the panel corner.
This commit is contained in:
Marco Beretta 2026-07-31 02:24:01 +02:00
parent 0cb2270ba0
commit a498f0cc5f
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
17 changed files with 273 additions and 55 deletions

View file

@ -5,6 +5,7 @@ import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { MouseEvent } from 'react';
import MessageIcon from '~/components/Share/MessageIcon';
import { toolPanelSpacingClassName } from './disclosure';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { useAgentsMapContext } from '~/Providers';
import { cn } from '~/utils';
@ -64,7 +65,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
: localize('com_ui_copy_to_clipboard');
return (
<div className="my-1.5">
<div className="my-2">
<button
type="button"
className={cn(
@ -104,7 +105,12 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (
<div className="group/handoff my-2 ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs">
<div
className={cn(
toolPanelSpacingClassName,
'group/handoff ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs',
)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_handoff_instructions')}

View file

@ -60,7 +60,7 @@ export default function CodeAnalyze({
<span className="sr-only" aria-live="polite" aria-atomic="true">
{phase === 'running' ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')}
</span>
<div className="my-1 flex items-center gap-2.5">
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
phase={phase}
onClick={handleToggleCode}

View file

@ -7,6 +7,7 @@ import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import CopyButton from '~/components/Messages/Content/CopyButton';
import LangIcon from '~/components/Messages/Content/LangIcon';
import { toolPanelSpacingClassName } from '../disclosure';
import { sandboxStartingByToolCallId } from '~/store';
import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
@ -156,7 +157,12 @@ export default function BashCall({
</div>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
<div className="my-2 overflow-hidden rounded-lg border border-border-light">
<div
className={cn(
toolPanelSpacingClassName,
'overflow-hidden rounded-lg border border-border-light',
)}
>
{command && (
<div
ref={commandPaneRef}

View file

@ -4,6 +4,7 @@ import { SquareTerminal } from 'lucide-react';
import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { toolPanelSpacingClassName } from '../disclosure';
import { sandboxStartingByToolCallId } from '~/store';
import useLazyHighlight from './useLazyHighlight';
import useToolCallState from './useToolCallState';
@ -168,7 +169,12 @@ export default function ExecuteCode({
</div>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
<div
className={cn(
toolPanelSpacingClassName,
'overflow-hidden rounded-lg border border-border-light bg-surface-secondary',
)}
>
{code && <CodeWindowHeader language={lang} code={code} />}
{code && (
<pre

View file

@ -37,7 +37,7 @@ export default function PendingSkillCall({
<div className={TOOL_ROW_CLASSES}>
<div className="progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
<div
className="progress-text-content absolute left-0 top-0 max-w-full overflow-visible whitespace-nowrap"
className="progress-text-content absolute left-0 right-0 top-0 max-w-full overflow-visible whitespace-nowrap"
style={{ opacity: 1, transform: 'none' }}
>
<div className="inline-flex w-full items-center gap-2">

View file

@ -4,6 +4,7 @@ import { Lightbulb, ChevronDown } from 'lucide-react';
import { ContentTypes } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { ThinkingContent, ThinkingButton, ThinkingLabel, FloatingThinkingBar, useInViewport } from './Thinking';
import { disclosureChevronClassName } from '~/components/Chat/Messages/Content/disclosure';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
@ -257,6 +258,7 @@ Reasoning.displayName = 'Reasoning';
type ReasoningCompactProps = {
reasoning: string;
label: string;
isAfterTool?: boolean;
/** True while this is the reasoning part currently streaming. Drives the
* collapsed grok-style live peek. */
isStreaming?: boolean;
@ -270,7 +272,7 @@ type ReasoningCompactProps = {
* a floating collapse + copy bar inside the rounded content panel.
*/
export const ReasoningCompact = memo(
({ reasoning, label, isStreaming = false }: ReasoningCompactProps) => {
({ reasoning, label, isAfterTool = false, isStreaming = false }: ReasoningCompactProps) => {
const contentId = useId();
const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom);
@ -314,25 +316,26 @@ export const ReasoningCompact = memo(
return (
<div
ref={containerRef}
className="group/reasoning-compact"
className={cn('group/reasoning-compact mb-1', isAfterTool ? 'mt-0' : 'mt-1')}
onMouseEnter={revealBar}
onMouseLeave={hideBar}
onFocus={revealBar}
onBlur={handleBlur}
>
<div ref={headerRef} className="relative my-1.5 flex h-5 shrink-0 items-center gap-1.5">
<div ref={headerRef} className="relative flex h-5 shrink-0 items-center gap-1.5">
<button
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={contentId}
className="inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
className="group/disclosure inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
<Lightbulb className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="tool-status-text font-medium">{label}</span>
<ChevronDown
className={cn(
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
disclosureChevronClassName,
'size-4 translate-y-[1px]',
isExpanded && 'rotate-180',
)}
aria-hidden="true"

View file

@ -388,7 +388,7 @@ export default function SubagentCall({
data-subagent-parent-message={parentMessageId}
data-subagent-part-index={partIndex}
className={cn(
'my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition',
'my-2 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition',
canOpenDetails ? 'group hover:bg-surface-tertiary' : 'cursor-default opacity-80',
running && !detachedStatusUnknown && 'animate-pulse-slow',
)}

View file

@ -110,14 +110,14 @@ export const ThinkingButton = memo(
}, [content]);
return (
<div className="group/thinking flex w-full items-center justify-between gap-2">
<div className="group/thinking relative flex w-full items-center">
<button
type="button"
onClick={onClick}
aria-expanded={isExpanded}
aria-controls={contentId}
className={cn(
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
'group/button flex flex-1 items-center justify-start rounded-lg pr-10 leading-[18px]',
fontSize,
)}
>
@ -177,7 +177,7 @@ export const ThinkingButton = memo(
label={localize('com_ui_copy_thoughts_to_clipboard')}
copiedLabel={localize('com_ui_copied_to_clipboard')}
className={cn(
'opacity-0 transition-opacity',
'absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-opacity',
'group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100',
'focus-visible:opacity-100',
)}

View file

@ -0,0 +1,62 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ReasoningCompact } from '../Reasoning';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useExpandCollapse: (isExpanded: boolean) => ({
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
ref: { current: null },
}),
}));
jest.mock('../Thinking', () => ({
ThinkingContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
ThinkingButton: () => <button type="button">{'thinking'}</button>,
FloatingThinkingBar: () => null,
useInViewport: () => ({ ref: { current: null }, inViewport: true }),
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
__esModule: true,
default: () => <button type="button">{'copy'}</button>,
}));
jest.mock('lucide-react', () => ({
Lightbulb: () => <span data-testid="thoughts-icon" />,
ChevronDown: ({ className }: { className?: string }) => (
<span data-testid="thoughts-chevron" className={className} />
),
}));
describe('ReasoningCompact', () => {
it('reveals its own Thoughts chevron instantly on row hover or focus', () => {
render(<ReasoningCompact reasoning="A useful thought" label="Thoughts" />);
const button = screen.getByRole('button', { name: 'Thoughts' });
const chevron = screen.getByTestId('thoughts-chevron');
const row = button.closest('.group\\/reasoning-compact');
expect(row).toHaveClass('mb-1', 'mt-1');
expect(button.parentElement).not.toHaveClass('my-1.5');
expect(button).toHaveClass('group/disclosure');
expect(chevron).toHaveClass(
'opacity-0',
'group-hover/disclosure:opacity-100',
'group-focus-within/disclosure:opacity-100',
);
expect(chevron).toHaveClass('transition-transform');
expect(chevron.className).not.toContain('transition-opacity');
});
it('removes the extra top margin when Thoughts follows a tool', () => {
render(<ReasoningCompact reasoning="A useful thought" label="Thoughts" isAfterTool />);
const row = screen
.getByRole('button', { name: 'Thoughts' })
.closest('.group\\/reasoning-compact');
expect(row).toHaveClass('mb-1', 'mt-0');
expect(row).not.toHaveClass('mt-1');
});
});

View file

@ -0,0 +1,45 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThinkingButton } from '../Thinking';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: () => 'text-base',
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => (
<button type="button" data-testid="copy-thoughts" className={className} />
),
}));
describe('ThinkingButton', () => {
const props = {
onClick: jest.fn(),
label: 'Thoughts',
content: 'A useful thought',
contentId: 'thoughts-content',
};
it('keeps the expanded copy control out of the header layout flow', () => {
const { rerender } = render(<ThinkingButton {...props} isExpanded={false} />);
const toggle = screen.getByRole('button', { name: 'Thoughts' });
expect(toggle.parentElement).toHaveClass('relative');
expect(screen.queryByTestId('copy-thoughts')).not.toBeInTheDocument();
rerender(<ThinkingButton {...props} isExpanded />);
expect(screen.getByTestId('copy-thoughts')).toHaveClass(
'absolute',
'right-0',
'top-1/2',
'-translate-y-1/2',
);
});
});

View file

@ -5,6 +5,7 @@ import * as Popover from '@radix-ui/react-popover';
import { isReportableRunStepDuration } from 'librechat-data-provider';
import type { ToolCallPhase } from '~/utils/toolCallPhase';
import { cn, getRunStepDurationLabels } from '~/utils';
import { disclosureChevronClassName } from './disclosure';
import CancelledIcon from './CancelledIcon';
import { ROW_GLYPH_SLOT } from './rows';
import { useLocalize } from '~/hooks';
@ -12,11 +13,11 @@ import { useLocalize } from '~/hooks';
const wrapperClass =
'progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5';
/** `max-w-full` caps the absolutely-positioned line at the message column;
* the label span truncates itself, so overflow stays visible for the
/** `right-0` and `max-w-full` cap the absolutely-positioned line at the message
* column; the label span truncates itself, so overflow stays visible for the
* button's focus ring. */
const contentClass =
'progress-text-content absolute left-0 top-0 max-w-full overflow-visible whitespace-nowrap';
'progress-text-content absolute left-0 right-0 top-0 max-w-full overflow-visible whitespace-nowrap';
const Wrapper = ({ popover, children }: { popover: boolean; children: React.ReactNode }) => {
if (popover) {
@ -104,7 +105,7 @@ export default function ProgressText({
type="button"
variant="ghost"
className={cn(
'h-auto w-full justify-start gap-2 rounded-none p-0 hover:bg-transparent hover:text-inherit disabled:opacity-100',
'group/disclosure inline-flex h-auto w-full items-center justify-start gap-2 rounded-none p-0 hover:bg-transparent hover:text-inherit disabled:opacity-100',
hasInput
? 'focus-visible:ring-border-heavy focus-visible:ring-offset-0'
: 'pointer-events-none',
@ -151,7 +152,8 @@ export default function ProgressText({
{hasInput && (
<ChevronDown
className={cn(
'size-4 shrink-0 translate-y-[1px] transition-transform duration-200 ease-out',
disclosureChevronClassName,
'size-4 translate-y-[1px]',
isExpanded && 'rotate-180',
)}
aria-hidden="true"

View file

@ -114,32 +114,34 @@ export default function OutputRenderer({ text }: OutputRendererProps) {
const structured = !isJson && isStructuredText(displayText);
return (
<div className="relative">
{isJson ? (
<pre className="max-h-[300px] overflow-auto rounded text-xs">
<code className="hljs language-json !whitespace-pre-wrap !break-words">
<div>
<div className="relative pr-10">
{isJson ? (
<pre className="max-h-[300px] overflow-auto rounded text-xs">
<code className="hljs language-json !whitespace-pre-wrap !break-words">
{visibleText}
</code>
</pre>
) : (
<pre
className={cn(
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words text-xs',
error && 'font-mono text-status-error',
!error && structured && 'font-mono text-text-secondary',
!error && !structured && 'font-sans text-sm text-text-primary',
)}
>
{visibleText}
</code>
</pre>
) : (
<pre
className={cn(
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words text-xs',
error && 'font-mono text-status-error',
!error && structured && 'font-mono text-text-secondary',
!error && !structured && 'font-sans text-sm text-text-primary',
)}
>
{visibleText}
</pre>
)}
<div className="absolute bottom-0 right-0">
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
iconOnly
label={localize('com_ui_copy')}
/>
</pre>
)}
<div className="absolute right-0 top-1/2 -translate-y-1/2">
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
iconOnly
label={localize('com_ui_copy')}
/>
</div>
</div>
{needsTruncation && (
<Button

View file

@ -0,0 +1,24 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import OutputRenderer from '../OutputRenderer';
jest.mock('copy-to-clipboard', () => jest.fn());
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
__esModule: true,
default: () => <button type="button" data-testid="copy-output" />,
}));
describe('OutputRenderer', () => {
it('vertically centers the copy control beside the output', () => {
render(<OutputRenderer text={'First line\nSecond line'} />);
const copyPositioner = screen.getByTestId('copy-output').parentElement;
expect(copyPositioner).toHaveClass('absolute', 'right-0', 'top-1/2', '-translate-y-1/2');
expect(copyPositioner?.parentElement).toHaveClass('relative', 'pr-10');
});
});

View file

@ -8,6 +8,7 @@ import type {
SearchResultData,
PartMetadata,
} from 'librechat-data-provider';
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import { StackedFavicons } from '~/components/Web/Sources';
@ -237,14 +238,14 @@ export default function WebSearch({
const completedText = intent ?? localize('com_ui_web_searched');
return (
<div className="mb-2">
<div className="my-1">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{completedText}
</span>
<button
type="button"
className={cn(
'tool-status-text group flex items-center gap-2 rounded-full py-1 transition-colors',
'tool-status-text group/disclosure flex h-5 items-center gap-2 rounded-full',
hasSourceData
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none text-text-secondary',
@ -268,10 +269,7 @@ export default function WebSearch({
<span className="min-w-0 truncate font-medium">{completedText}</span>
{hasSourceData && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-text-secondary transition-transform duration-200',
showSourceList && 'rotate-180',
)}
className={cn(disclosureChevronClassName, 'size-3.5', showSourceList && 'rotate-180')}
aria-hidden="true"
/>
)}
@ -280,7 +278,12 @@ export default function WebSearch({
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
<div className="overflow-hidden" ref={sourceExpandRef}>
{shouldRenderBody && (
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
<div
className={cn(
toolPanelSpacingClassName,
'max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
)}
>
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
return (
@ -312,7 +315,7 @@ export default function WebSearch({
}
return (
<div className="my-1 flex items-center gap-2">
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{genericProgressText}
</span>

View file

@ -1,5 +1,5 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import ProgressText from '../ProgressText';
jest.mock('~/hooks', () => ({
@ -111,3 +111,35 @@ describe('ProgressText duration', () => {
});
});
});
describe('ProgressText disclosure', () => {
it('shows an expandable chevron only on hover or focus', () => {
const onClick = jest.fn();
renderProgressText({ hasInput: true, onClick });
const button = screen.getByRole('button', { name: 'Completed foo' });
const chevron = button.querySelector('svg');
expect(button).toHaveClass('group/disclosure');
expect(chevron).toHaveClass(
'opacity-0',
'group-hover/disclosure:opacity-100',
'group-focus-within/disclosure:opacity-100',
);
expect(chevron).toHaveClass('transition-transform');
expect(chevron?.getAttribute('class')).not.toContain('transition-opacity');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders no chevron or disclosure state without expandable content', () => {
renderProgressText({ hasInput: false });
const button = screen.getByRole('button', { name: 'Completed foo' });
expect(button).toBeDisabled();
expect(button).not.toHaveAttribute('aria-expanded');
expect(button.querySelector('svg')).toBeNull();
});
});

View file

@ -56,7 +56,9 @@ jest.mock('~/components/Web/Sources', () => ({
jest.mock('lucide-react', () => ({
Globe: () => <span data-testid="globe-icon" />,
ChevronDown: () => <span data-testid="chevron-icon" />,
ChevronDown: ({ className }: { className?: string }) => (
<span data-testid="chevron-icon" className={className} />
),
}));
function makeSource(link: string, title: string): ValidSource {
@ -289,6 +291,27 @@ describe('WebSearch', () => {
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('uses standard tool-row spacing and reveals its chevron on hover or focus', () => {
const searchResults = makeSearchResults({
0: { organic: [makeSource('https://example.com', 'Example')] },
});
renderWebSearch({ searchResults });
const button = screen.getByRole('button', { name: /Searched the web/ });
expect(button.parentElement).toHaveClass('my-1');
expect(button).not.toHaveClass('py-1');
expect(button).not.toHaveClass('transition-colors');
const chevron = screen.getByTestId('chevron-icon');
expect(chevron).toHaveClass(
'opacity-0',
'group-hover/disclosure:opacity-100',
'group-focus-within/disclosure:opacity-100',
);
expect(chevron).toHaveClass('transition-transform');
expect(chevron.className).not.toContain('transition-opacity');
});
it('renders searching state during streaming', () => {
renderWebSearch({
isSubmitting: true,

View file

@ -0,0 +1,4 @@
export const disclosureChevronClassName =
'shrink-0 text-text-secondary opacity-0 transition-transform duration-200 ease-out group-focus-within/disclosure:opacity-100 group-hover/disclosure:opacity-100 motion-reduce:transition-none';
export const toolPanelSpacingClassName = 'mb-2 mt-0';