💄 style: Align the Thinking Dot with the Header Icon (#14895)

* 💄 style: Align the Thinking Dot with the Header Icon

* 💄 style: Keep the Dot Nudge Logical and Gated to the Header Axis

* 💄 style: Route the Seeded Empty-Text Placeholder Through the Nudged Cursor

* ♻️ refactor: Guard MemoryArtifacts on Its Memoized List
This commit is contained in:
Danny Avila 2026-08-16 17:56:22 -04:00 committed by GitHub
parent 832bac39ad
commit 2b1644406a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 176 additions and 63 deletions

View file

@ -11,18 +11,28 @@ import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import ActivityPhaseGroup from './ActivityPhaseGroup';
import EditContentParts from './EditContentParts';
import { EmptyText, AgentUpdate } from './Parts';
import ApprovalProvider from './ApprovalContext';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import ToolCallGroup from './ToolCallGroup';
import Container from './Container';
import Part from './Part';
/** An empty TEXT part the placeholder some endpoints seed in
* `initialResponse.content` before the model produces anything. */
const isEmptyTextPart = (part: TMessageContentParts | undefined): boolean => {
if (part == null || part.type !== ContentTypes.TEXT) {
return false;
}
const text = typeof part.text === 'string' ? part.text : (part.text?.value ?? '');
return text.length === 0;
};
const getToolCallId = (part: TMessageContentParts): string =>
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
@ -296,17 +306,7 @@ const ContentParts = memo(function ContentParts({
* the transition before the model has actually produced anything.
*/
const hasRealContent = useMemo(
() =>
(content ?? []).some((part) => {
if (part == null) {
return false;
}
if (part.type !== ContentTypes.TEXT) {
return true;
}
const text = typeof part.text === 'string' ? part.text : (part.text?.value ?? '');
return text.length > 0;
}),
() => (content ?? []).some((part) => part != null && !isEmptyTextPart(part)),
[content],
);
@ -561,7 +561,13 @@ const ContentParts = memo(function ContentParts({
}
const safeContent = content ?? [];
const showEmptyCursor = safeContent.length === 0 && effectiveIsSubmitting;
/** A solitary seeded empty TEXT part (useChatFunctions' assistant-side
* placeholder) is the same waiting state as no content at all route it
* through EmptyText instead of Markdown's flush initializing dot so both
* flows share the gated header-axis nudge. Never solitary mid-stream, so
* empty TEXT after real parts keeps its flush in-flow cursor. */
const solitaryEmptyText = safeContent.length === 1 && isEmptyTextPart(safeContent[0]);
const showEmptyCursor = (safeContent.length === 0 || solitaryEmptyText) && effectiveIsSubmitting;
/** Skips trailing BLANK label reservations they render nothing, and
* counting one as last would strip the streaming cursor from the last
* VISIBLE part until the next delta. */
@ -604,45 +610,52 @@ const ContentParts = memo(function ContentParts({
{!nestedActivityPhase && renderPendingSkills()}
{showEmptyCursor && (
<Container>
<EmptyText />
{/** Nudge only when the dot is truly first under the header leading
* memory/skill rows and nested phases keep it flush. */}
<EmptyText
underHeaderIcon={
!nestedActivityPhase && !hasPendingSkills && !hasMemoryArtifacts(attachments)
}
/>
</Container>
)}
{groupedParts.flatMap((group) => {
const firstIdx = group.type === 'single' ? group.part.idx : (group.parts[0]?.idx ?? -1);
const nodes: ReactElement[] = [];
const attribution = renderResumeAttribution(firstIdx);
if (attribution != null) {
nodes.push(attribution);
}
if (group.type === 'single') {
const { part, idx } = group.part;
nodes.push(renderPart(part, idx, idx === lastContentIdx));
{!showEmptyCursor &&
groupedParts.flatMap((group) => {
const firstIdx = group.type === 'single' ? group.part.idx : (group.parts[0]?.idx ?? -1);
const nodes: ReactElement[] = [];
const attribution = renderResumeAttribution(firstIdx);
if (attribution != null) {
nodes.push(attribution);
}
if (group.type === 'single') {
const { part, idx } = group.part;
nodes.push(renderPart(part, idx, idx === lastContentIdx));
return nodes;
}
const { groupId } = group;
nodes.push(
<ToolCallGroup
key={`tool-group-${groupId}`}
parts={group.parts}
isSubmitting={effectiveIsSubmitting}
/** The label part is CONSUMED into the header, not listed in
* `parts` a filled label at the content tail must still
* mark its group as last or nothing holds the streaming
* cursor until the next delta. */
isLast={
group.parts.some((p) => p.idx === lastContentIdx) ||
group.labelPart?.idx === lastContentIdx
}
renderPart={renderGroupedPart}
lastContentIdx={lastContentIdx}
groupAttachments={group.groupAttachments}
initialExpansionState={expansionState.get(groupId)}
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
labelPart={group.labelPart}
/>,
);
return nodes;
}
const { groupId } = group;
nodes.push(
<ToolCallGroup
key={`tool-group-${groupId}`}
parts={group.parts}
isSubmitting={effectiveIsSubmitting}
/** The label part is CONSUMED into the header, not listed in
* `parts` a filled label at the content tail must still
* mark its group as last or nothing holds the streaming
* cursor until the next delta. */
isLast={
group.parts.some((p) => p.idx === lastContentIdx) ||
group.labelPart?.idx === lastContentIdx
}
renderPart={renderGroupedPart}
lastContentIdx={lastContentIdx}
groupAttachments={group.groupAttachments}
initialExpansionState={expansionState.get(groupId)}
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
labelPart={group.labelPart}
/>,
);
return nodes;
})}
})}
</SearchContext.Provider>
);
if (nestedActivityPhase) {

View file

@ -5,6 +5,14 @@ import MemoryInfo from './MemoryInfo';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/** Layout-gate predicate for callers that arrange around this component
* (e.g. the thinking-dot nudge). Must stay in agreement with the memo's
* collection condition inside the component both key on
* `attachment[Tools.memory]`. The component itself guards on its memoized
* list instead, avoiding a second pass per render. */
export const hasMemoryArtifacts = (attachments?: TAttachment[]): boolean =>
attachments?.some((attachment) => attachment?.[Tools.memory] != null) ?? false;
export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) {
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
@ -77,7 +85,7 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
};
}, [showInfo, isAnimating]);
if (!memoryArtifacts || memoryArtifacts.length === 0) {
if (memoryArtifacts.length === 0) {
return null;
}

View file

@ -6,6 +6,7 @@ import type { TMessageContentProps, TDisplayProps } from '~/common';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import Error from '~/components/Messages/Content/Error';
import { useMessageContext } from '~/Providers';
import EmptyText from './Parts/EmptyText';
import MarkdownLite from './MarkdownLite';
import EditMessage from './EditMessage';
import Thinking from './Parts/Thinking';
@ -28,14 +29,8 @@ const parseThinkingContent = (text: string) => {
};
const LoadingFallback = () => (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
</p>
</div>
</div>
<div className="mb-[0.625rem]">
<EmptyText underHeaderIcon />
</div>
);

View file

@ -1,11 +1,24 @@
import { memo } from 'react';
import { cn } from '~/utils';
type EmptyTextPartProps = {
/**
* Centers the 12px dot (style.css `.result-thinking`) on the axis of the
* size-6 message-header icon above it: (24 12) / 2, as inline-start
* padding so the axis holds when the document flips to RTL. Only for
* placeholders rendering directly beneath the header leading rows and
* nested contexts (activity groups, parallel columns, mid-stream parts)
* keep the flush default.
*/
underHeaderIcon?: boolean;
};
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
const EmptyTextPart = memo(() => {
const EmptyTextPart = memo(({ underHeaderIcon = false }: EmptyTextPartProps) => {
return (
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<div className={cn('absolute', underHeaderIcon && 'ps-1.5')}>
<p className="submitting relative">
<span className="result-thinking" />
</p>

View file

@ -0,0 +1,24 @@
import React from 'react';
import { render } from '@testing-library/react';
import EmptyText from '../EmptyText';
const dotWrapper = (container: HTMLElement) =>
container.querySelector('.result-thinking')?.closest('div');
describe('EmptyText', () => {
it('keeps the dot flush with the content edge by default', () => {
const { container } = render(<EmptyText />);
const wrapper = dotWrapper(container);
expect(wrapper).toHaveClass('absolute');
expect(wrapper).not.toHaveClass('ps-1.5');
});
it('centers the dot on the header icon axis when it sits directly beneath one', () => {
const { container } = render(<EmptyText underHeaderIcon />);
const wrapper = dotWrapper(container);
expect(wrapper).toHaveClass('absolute');
/** Logical (inline-start) padding: the header icon sits at inline-start,
* so the nudge must mirror with the document direction under RTL. */
expect(wrapper).toHaveClass('ps-1.5');
});
});

View file

@ -1,7 +1,7 @@
import React from 'react';
import { ContentTypes } from 'librechat-data-provider';
import { ContentTypes, Tools } from 'librechat-data-provider';
import { fireEvent, render, screen } from '@testing-library/react';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
import { groupSequentialToolCalls } from '~/utils';
jest.mock('~/utils', () => ({
@ -30,7 +30,9 @@ jest.mock('~/Providers', () => {
});
jest.mock('../Parts', () => ({
EmptyText: () => <div data-testid="empty-text" />,
EmptyText: ({ underHeaderIcon }: { underHeaderIcon?: boolean }) => (
<div data-testid="empty-text" data-under-header-icon={String(underHeaderIcon === true)} />
),
AgentUpdate: ({ currentAgentId }: { currentAgentId: string }) => (
<div data-testid="post-steer-agent-update" data-agent-id={currentAgentId} />
),
@ -39,6 +41,9 @@ jest.mock('../Parts', () => ({
jest.mock('../MemoryArtifacts', () => ({
__esModule: true,
default: () => <div data-testid="memory-artifacts" />,
hasMemoryArtifacts:
jest.requireActual<typeof import('../MemoryArtifacts')>('../MemoryArtifacts')
.hasMemoryArtifacts,
}));
jest.mock('../Parts/PendingSkillCall', () => ({
@ -207,6 +212,61 @@ describe('ContentParts — interim skill cards', () => {
});
});
describe('ContentParts — thinking-dot header alignment', () => {
const submittingProps = { ...baseProps, isSubmitting: true, isLatestMessage: true };
const memoryAttachment = {
type: Tools.memory,
[Tools.memory]: { type: 'update', key: 'user', value: 'test value' },
} as TAttachment;
it('nudges the cursor onto the header-icon axis when nothing precedes it', () => {
render(<ContentParts {...submittingProps} />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'true');
});
it('keeps the cursor flush when pending skill rows render above it', () => {
render(<ContentParts {...submittingProps} manualSkills={['pptx']} />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'false');
});
it('keeps the cursor flush when a memory-artifact row renders above it', () => {
render(<ContentParts {...submittingProps} attachments={[memoryAttachment]} />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'false');
});
it('keeps the cursor flush inside a nested activity phase', () => {
render(<ContentParts {...submittingProps} nestedActivityPhase />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'false');
});
it('routes a solitary seeded empty text part through the nudged placeholder', () => {
const seeded = [{ type: ContentTypes.TEXT, text: { value: '' } }] as TMessageContentParts[];
render(<ContentParts {...submittingProps} content={seeded} />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'true');
expect(screen.queryByTestId('real-part-text')).toBeNull();
});
it('keeps the gate for a seeded placeholder behind pending skill rows', () => {
const seeded = [{ type: ContentTypes.TEXT, text: '' }] as TMessageContentParts[];
render(<ContentParts {...submittingProps} content={seeded} manualSkills={['pptx']} />);
expect(screen.getByTestId('empty-text')).toHaveAttribute('data-under-header-icon', 'false');
});
it('renders a persisted empty text part normally once submission ends', () => {
const seeded = [{ type: ContentTypes.TEXT, text: { value: '' } }] as TMessageContentParts[];
render(<ContentParts {...baseProps} content={seeded} />);
expect(screen.queryByTestId('empty-text')).toBeNull();
expect(screen.getByTestId('real-part-text')).toBeInTheDocument();
});
it('leaves real text content out of the placeholder path', () => {
const textContent = [{ type: ContentTypes.TEXT, text: 'hello' }] as TMessageContentParts[];
render(<ContentParts {...submittingProps} content={textContent} />);
expect(screen.queryByTestId('empty-text')).toBeNull();
expect(screen.getByTestId('real-part-text')).toBeInTheDocument();
});
});
describe('ContentParts — post-steer author re-attribution', () => {
const steerPart = {
type: ContentTypes.STEER,