refactor(client): align memory updates with the standard tool row

Rebuild MemoryArtifacts on the shared disclosure row (useExpandCollapse
grid animation, hover-reveal chevron, h-5 header) in place of its
bespoke ResizeObserver height animation, and simplify the expanded
panel: section headings dropped in favor of bold memory keys, a single
p-3 inset instead of double padding, and the now-unused heading
translation keys removed.
This commit is contained in:
Marco Beretta 2026-07-31 02:26:06 +02:00
parent 2c2e3d78f5
commit 475dc8e8b1
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 120 additions and 157 deletions

View file

@ -1,17 +1,17 @@
import { useId, useMemo, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { useState, useRef, useMemo, useLayoutEffect, useEffect } from 'react';
import type { MemoryArtifact, TAttachment } from 'librechat-data-provider';
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
import { useExpandCollapse, useLocalize } from '~/hooks';
import MemoryInfo from './MemoryInfo';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) {
const contentId = useId();
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const prevShowInfoRef = useRef<boolean>(showInfo);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
const { hasErrors, memoryArtifacts } = useMemo(() => {
let hasErrors = false;
@ -34,122 +34,75 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
return { hasErrors, memoryArtifacts: result };
}, [attachments]);
useLayoutEffect(() => {
if (showInfo !== prevShowInfoRef.current) {
prevShowInfoRef.current = showInfo;
setIsAnimating(true);
if (showInfo && contentRef.current) {
requestAnimationFrame(() => {
if (contentRef.current) {
const height = contentRef.current.scrollHeight;
setContentHeight(height + 4);
}
});
} else {
setContentHeight(0);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 400);
return () => clearTimeout(timer);
}
}, [showInfo]);
useEffect(() => {
if (!contentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showInfo && !isAnimating) {
for (const entry of entries) {
if (entry.target === contentRef.current) {
setContentHeight(entry.contentRect.height + 4);
}
}
}
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showInfo, isAnimating]);
if (!memoryArtifacts || memoryArtifacts.length === 0) {
return null;
}
return (
<>
<div className="flex items-center">
<div className="inline-block">
<button
className={cn(
'outline-hidden my-1 flex items-center gap-1 text-sm font-semibold transition-colors',
hasErrors
? 'text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-500'
: 'text-text-secondary-alt hover:text-text-primary',
)}
type="button"
onClick={() => setShowInfo((prev) => !prev)}
aria-expanded={showInfo}
aria-label={localize('com_ui_memory_updated')}
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<button
className={cn(
'group/disclosure inline-flex w-full items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
hasErrors
? 'text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-500'
: 'text-text-secondary',
)}
type="button"
onClick={() => setShowInfo((prev) => !prev)}
aria-expanded={showInfo}
aria-controls={contentId}
aria-label={localize('com_ui_memory_updated')}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4 shrink-0"
aria-hidden="true"
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mb-[-1px]"
>
<path
d="M6 3C4.89543 3 4 3.89543 4 5V13C4 14.1046 4.89543 15 6 15L6 3Z"
fill="currentColor"
/>
<path
d="M7 3V15H8.18037L8.4899 13.4523C8.54798 13.1619 8.69071 12.8952 8.90012 12.6858L12.2931 9.29289C12.7644 8.82153 13.3822 8.58583 14 8.58578V3.5C14 3.22386 13.7761 3 13.5 3H7Z"
fill="currentColor"
/>
<path
d="M11.3512 15.5297L9.73505 15.8529C9.38519 15.9229 9.07673 15.6144 9.14671 15.2646L9.46993 13.6484C9.48929 13.5517 9.53687 13.4628 9.60667 13.393L12.9996 10C13.5519 9.44771 14.4473 9.44771 14.9996 10C15.5519 10.5523 15.5519 11.4477 14.9996 12L11.6067 15.393C11.5369 15.4628 11.448 15.5103 11.3512 15.5297Z"
fill="currentColor"
/>
</svg>
<path
d="M6 3C4.89543 3 4 3.89543 4 5V13C4 14.1046 4.89543 15 6 15L6 3Z"
fill="currentColor"
/>
<path
d="M7 3V15H8.18037L8.4899 13.4523C8.54798 13.1619 8.69071 12.8952 8.90012 12.6858L12.2931 9.29289C12.7644 8.82153 13.3822 8.58583 14 8.58578V3.5C14 3.22386 13.7761 3 13.5 3H7Z"
fill="currentColor"
/>
<path
d="M11.3512 15.5297L9.73505 15.8529C9.38519 15.9229 9.07673 15.6144 9.14671 15.2646L9.46993 13.6484C9.48929 13.5517 9.53687 13.4628 9.60667 13.393L12.9996 10C13.5519 9.44771 14.4473 9.44771 14.9996 10C15.5519 10.5523 15.5519 11.4477 14.9996 12L11.6067 15.393C11.5369 15.4628 11.448 15.5103 11.3512 15.5297Z"
fill="currentColor"
/>
</svg>
<span className="tool-status-text font-medium">
{hasErrors ? localize('com_ui_memory_error') : localize('com_ui_memory_updated')}
</button>
</div>
</span>
<ChevronDown
className={cn(
disclosureChevronClassName,
'size-4 translate-y-[1px]',
showInfo && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
</div>
<div
className="relative"
style={{
height: showInfo ? contentHeight : 0,
overflow: 'hidden',
transition:
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: showInfo ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
id={contentId}
style={expandStyle}
role="group"
aria-label={localize('com_ui_memory_updated')}
aria-hidden={!showInfo || undefined}
>
<div
className={cn(
'overflow-hidden rounded-xl border border-border-light bg-surface-primary-alt shadow-md',
showInfo && 'shadow-lg',
)}
style={{
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showInfo ? 1 : 0,
transition:
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<div ref={contentRef}>
<div className="overflow-hidden" ref={expandRef}>
<div
className={cn(
toolPanelSpacingClassName,
'overflow-hidden rounded-lg border border-border-light bg-surface-secondary',
)}
>
{showInfo && <MemoryInfo key="memory-info" memoryArtifacts={memoryArtifacts} />}
</div>
</div>

View file

@ -1,5 +1,5 @@
import type { MemoryArtifact } from 'librechat-data-provider';
import { useMemo } from 'react';
import type { MemoryArtifact } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) {
@ -46,44 +46,32 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
}
return (
<div className="space-y-4 p-4">
<div className="space-y-3 p-3">
{updatedMemories.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold text-text-primary">
{localize('com_ui_memory_updated_items')}
</h4>
<div className="space-y-2">
{updatedMemories.map((artifact) => (
<div key={`update-${artifact.key}`} className="rounded-lg p-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
<div className="whitespace-pre-wrap text-sm text-text-primary">
{artifact.value}
</div>
<div className="space-y-3">
{updatedMemories.map((artifact) => (
<div key={`update-${artifact.key}`}>
<div className="mb-1 text-xs font-bold uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
))}
</div>
<div className="whitespace-pre-wrap text-sm text-text-primary">{artifact.value}</div>
</div>
))}
</div>
)}
{deletedMemories.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold text-text-primary">
{localize('com_ui_memory_deleted_items')}
</h4>
<div className="space-y-2">
{deletedMemories.map((artifact) => (
<div key={`delete-${artifact.key}`} className="rounded-lg p-3 opacity-60">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
<div className="text-sm italic text-text-secondary">
{localize('com_ui_memory_deleted')}
</div>
<div className="space-y-3">
{deletedMemories.map((artifact) => (
<div key={`delete-${artifact.key}`} className="opacity-60">
<div className="mb-1 text-xs font-bold uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
))}
</div>
<div className="text-sm italic text-text-secondary">
{localize('com_ui_memory_deleted')}
</div>
</div>
))}
</div>
)}

View file

@ -1,9 +1,9 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import MemoryArtifacts from '../MemoryArtifacts';
import type { TAttachment, MemoryArtifact } from 'librechat-data-provider';
import { Tools } from 'librechat-data-provider';
import type { TAttachment, MemoryArtifact } from 'librechat-data-provider';
import MemoryArtifacts from '../MemoryArtifacts';
// Mock the localize hook
jest.mock('~/hooks', () => ({
@ -14,6 +14,14 @@ jest.mock('~/hooks', () => ({
};
return translations[key] || key;
},
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
},
ref: { current: null },
}),
}));
// Mock the MemoryInfo component
@ -69,8 +77,7 @@ describe('MemoryArtifacts', () => {
render(<MemoryArtifacts attachments={attachments} />);
const button = screen.getByRole('button');
expect(button).toHaveClass('text-text-secondary-alt');
expect(button).toHaveClass('hover:text-text-primary');
expect(button).toHaveClass('text-text-secondary');
expect(button).not.toHaveClass('text-red-500');
});
@ -127,6 +134,25 @@ describe('MemoryArtifacts', () => {
});
describe('Collapse/Expand Functionality', () => {
test('uses the standard tool row and expanded panel styling', () => {
const attachments = [createMemoryAttachment('update', 'memory1')];
render(<MemoryArtifacts attachments={attachments} />);
const button = screen.getByRole('button');
expect(button.parentElement).toHaveClass('my-1', 'h-5');
expect(button).toHaveClass('group/disclosure');
fireEvent.click(button);
expect(screen.getByTestId('memory-info').parentElement).toHaveClass(
'mb-2',
'mt-0',
'rounded-lg',
'bg-surface-secondary',
);
});
test('toggles memory info visibility on button click', () => {
const attachments = [createMemoryAttachment('update', 'memory1')];

View file

@ -1,15 +1,13 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import MemoryInfo from '../MemoryInfo';
import type { MemoryArtifact } from 'librechat-data-provider';
import MemoryInfo from '../MemoryInfo';
// Mock the localize hook
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, params?: Record<string, any>) => {
const translations: Record<string, string> = {
com_ui_memory_updated_items: 'Updated Memories',
com_ui_memory_deleted_items: 'Deleted Memories',
com_ui_memory_already_exceeded: `Memory storage already full - exceeded by ${params?.tokens || 0} tokens. Delete existing memories before adding new ones.`,
com_ui_memory_would_exceed: `Cannot save - would exceed limit by ${params?.tokens || 0} tokens. Delete existing memories to make space.`,
com_ui_memory_deleted: 'This memory has been deleted',
@ -140,9 +138,9 @@ describe('MemoryInfo', () => {
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
// Check all sections are present
expect(screen.getByText('Updated Memories')).toBeInTheDocument();
expect(screen.getByText('Deleted Memories')).toBeInTheDocument();
// Entries render without section headings; only the error section keeps one
expect(screen.queryByText('Updated Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Deleted Memories')).not.toBeInTheDocument();
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
// Check content
@ -244,7 +242,7 @@ describe('MemoryInfo', () => {
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
expect(screen.getByText('Updated Memories')).toBeInTheDocument();
expect(screen.queryByText('Updated Memories')).not.toBeInTheDocument();
expect(screen.getByText('preferences')).toBeInTheDocument();
expect(screen.getByText('User prefers dark mode')).toBeInTheDocument();
expect(screen.getByText('location')).toBeInTheDocument();
@ -259,7 +257,7 @@ describe('MemoryInfo', () => {
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
expect(screen.getByText('Deleted Memories')).toBeInTheDocument();
expect(screen.queryByText('Deleted Memories')).not.toBeInTheDocument();
expect(screen.getByText('old_preference')).toBeInTheDocument();
expect(screen.getByText('outdated_info')).toBeInTheDocument();
});

View file

@ -1450,14 +1450,12 @@
"com_ui_memory_already_exceeded": "Memory storage already full - exceeded by {{tokens}} tokens. Delete existing memories before adding new ones.",
"com_ui_memory_created": "Memory created successfully",
"com_ui_memory_deleted": "Memory deleted",
"com_ui_memory_deleted_items": "Deleted Memories",
"com_ui_memory_error": "Memory Error",
"com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.",
"com_ui_memory_key_hint": "Use lowercase letters and underscores only",
"com_ui_memory_key_validation": "Memory key must only contain lowercase letters and underscores.",
"com_ui_memory_storage_full": "Memory Storage Full",
"com_ui_memory_updated": "Updated saved memory",
"com_ui_memory_updated_items": "Updated Memories",
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
"com_ui_mermaid": "mermaid",