mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
💄 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:
parent
832bac39ad
commit
2b1644406a
6 changed files with 176 additions and 63 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue