mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
✨ feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
* ✨ feat: Enhance Artifact Management with Version Control and UI Improvements ✨ feat: Improve mobile layout and responsiveness in Artifacts component ✨ feat: Refactor imports and remove unnecessary props in Artifact components ✨ feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions feat: Enhance artifact panel animations and improve UI responsiveness - Updated Thinking component button styles for smoother transitions. - Implemented dynamic rendering for artifacts panel with animation effects. - Refactored localization keys for consistency across multiple languages. - Added new CSS animations for iOS-inspired smooth transitions. - Improved Tailwind CSS configuration to support enhanced animation effects. ✨ feat: Add fullWidth and icon support to Radio component for enhanced flexibility refactor: Remove unused PreviewProps import in ArtifactPreview component refactor: Improve button class handling and blur effect constants in Artifact components ✨ feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI chore: Bump @librechat/client version to 0.3.2 refactor: Update button styles and transition durations for improved UI responsiveness refactor: revert back localization key refactor: remove unused scaling and animation properties for cleaner CSS refactor: remove unused animation properties for cleaner configuration * ✨ refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components * refactor: Remove cycleArtifact function from useArtifacts hook * ✨ feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component * ✨ feat: Update Badge component for responsive design and improve tap scaling behavior * chore: Update react-resizable-panels dependency to version 3.0.6 * ✨ feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles * ✨ style: Update text color for improved visibility in Artifacts component * ✨ style: Remove text color class for improved Spinner styling in Artifacts component * refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks * refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders * fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact' * feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management * fix: proper switch to preview as soon as artifact becomes enclosed * refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic * test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management * test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
4186db3ce2
commit
b8b1217c34
25 changed files with 1565 additions and 345 deletions
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
/** Mock dependencies */
|
||||
jest.mock('~/Providers', () => ({
|
||||
useArtifactsContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
logger: {
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
/** Mock store before importing */
|
||||
jest.mock('~/store', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
artifactsState: { key: 'artifactsState' },
|
||||
currentArtifactId: { key: 'currentArtifactId' },
|
||||
artifactsVisibility: { key: 'artifactsVisibility' },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('recoil', () => {
|
||||
const actualRecoil = jest.requireActual('recoil');
|
||||
return {
|
||||
...actualRecoil,
|
||||
useRecoilValue: jest.fn(),
|
||||
useRecoilState: jest.fn(),
|
||||
useResetRecoilState: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
/** Import mocked functions after mocking */
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { logger } from '~/utils';
|
||||
import useArtifacts from '../useArtifacts';
|
||||
|
||||
describe('useArtifacts', () => {
|
||||
const mockResetArtifacts = jest.fn();
|
||||
const mockResetCurrentArtifactId = jest.fn();
|
||||
const mockSetCurrentArtifactId = jest.fn();
|
||||
|
||||
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
|
||||
id: 'artifact-1',
|
||||
title: 'Test Artifact',
|
||||
type: 'application/vnd.react',
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
messageId: 'msg-1',
|
||||
lastUpdateTime: Date.now(),
|
||||
...partial,
|
||||
});
|
||||
|
||||
const defaultContext = {
|
||||
isSubmitting: false,
|
||||
latestMessageId: 'msg-1',
|
||||
latestMessageText: '',
|
||||
conversationId: 'conv-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue(defaultContext);
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
(useResetRecoilState as jest.Mock).mockImplementation((atom) => {
|
||||
if (atom?.key === 'artifactsState') {
|
||||
return mockResetArtifacts;
|
||||
}
|
||||
if (atom?.key === 'currentArtifactId') {
|
||||
return mockResetCurrentArtifactId;
|
||||
}
|
||||
return jest.fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with preview tab active', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should return null currentArtifact when no artifacts exist', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty orderedArtifactIds when no artifacts exist', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact ordering', () => {
|
||||
it('should order artifacts by lastUpdateTime', () => {
|
||||
const artifacts = {
|
||||
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual(['artifact-1', 'artifact-2', 'artifact-3']);
|
||||
});
|
||||
|
||||
it('should automatically select latest artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching - enclosed artifacts', () => {
|
||||
it('should switch to preview when enclosed artifact is detected during generation', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: false,
|
||||
latestMessageText: '',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Generation starts with enclosed artifact */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>\n:::',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should switch to preview when enclosed detected */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should not switch to preview if artifact is not enclosed', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Update with non-enclosed artifact */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should switch to code since artifact content is in message and not enclosed */
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
expect(logger.log).not.toHaveBeenCalledWith(
|
||||
'artifacts',
|
||||
expect.stringContaining('Enclosed artifact'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should only switch to preview once per artifact', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\ncode\n:::',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
const firstCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||
call[1]?.includes('Enclosed artifact'),
|
||||
).length;
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\ncode\n:::\nMore text',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
const secondCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||
call[1]?.includes('Enclosed artifact'),
|
||||
).length;
|
||||
|
||||
expect(secondCallCount).toBe(firstCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching - non-enclosed artifacts', () => {
|
||||
it('should switch to code when non-enclosed artifact content appears', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test Component</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Here is the code: const App = () => <div>Test Component</div>',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
});
|
||||
|
||||
it('should not switch to code if artifact content is not in message text', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Some other text here',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation changes', () => {
|
||||
it('should reset artifacts when conversation changes', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: 'conv-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset artifacts when navigating to new conversation from another conversation', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
/** Start with existing conversation (NOT Constants.NEW_CONVO) */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: 'existing-conv',
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
/** Navigate to NEW_CONVO - this should trigger the else if branch */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reset artifacts on initial render', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockResetArtifacts).not.toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset when transitioning from null to NEW_CONVO', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
/** Start with null conversationId */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
/** Transition to NEW_CONVO - triggers the else if branch (line 44) */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should reset because we're now on NEW_CONVO */
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset state flags when message ID changes', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
latestMessageId: 'msg-1',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
// First artifact becomes enclosed
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
// New message starts
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'New message',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
// Should allow switching again for the new message
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\nnew code\n:::',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup on unmount', () => {
|
||||
it('should reset artifacts when unmounting', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { unmount } = renderHook(() => useArtifacts());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
expect(logger.log).toHaveBeenCalledWith('artifacts_visibility', 'Unmounting artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual tab switching', () => {
|
||||
it('should allow manually switching tabs', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('code');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
});
|
||||
|
||||
it('should allow switching back to preview after manual switch to code', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('code');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('preview');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentIndex calculation', () => {
|
||||
it('should return correct index for current artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should return -1 for non-existent artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1' }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['non-existent', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.currentIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should detect and handle enclosed artifacts during generation', async () => {
|
||||
/** Start fresh with enclosed artifact already present */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Component"}\nconst App = () => <div>Test</div>\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Should detect enclosed pattern and be on preview */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts in sequence', () => {
|
||||
const artifact1 = createArtifact({ id: 'artifact-1', messageId: 'msg-1' });
|
||||
const artifact2 = createArtifact({ id: 'artifact-2', messageId: 'msg-2' });
|
||||
|
||||
/** First artifact */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode1\n:::',
|
||||
latestMessageId: 'msg-1',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
/** Second artifact starts (new message) */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({
|
||||
'artifact-1': artifact1,
|
||||
'artifact-2': artifact2,
|
||||
});
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Here is another one',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Second artifact becomes enclosed */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode2\n:::',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null artifacts gracefully', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined artifacts gracefully', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty latestMessageText', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: '',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle malformed artifact syntax', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact\ncode but no closing',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle artifact with only opening tag', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact content comparison', () => {
|
||||
it('should not switch tabs when artifact content does not change', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Some text',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
const initialTab = result.current.activeTab;
|
||||
|
||||
/** Same content, just rerender */
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe(initialTab);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubmitting state handling', () => {
|
||||
it('should process when isSubmitting is true', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
});
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still select latest artifact even when idle (via orderedArtifactIds effect)', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: false,
|
||||
latestMessageText: 'Some text',
|
||||
});
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
/** The orderedArtifactIds effect always runs when artifacts change */
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
|
||||
});
|
||||
|
||||
it('should not process when latestMessageId is null', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageId: null,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Main effect should exit early and not switch tabs */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex pattern matching', () => {
|
||||
it('should match artifact with title attribute', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="My Component"}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with multiple attributes', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test" type="react" identifier="comp-1"}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with code blocks inside', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\n```typescript\nconst x = 1;\n```\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with whitespace variations', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"} \n\n code here \n\n :::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { logger } from '~/utils';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { getKey } from '~/utils/artifacts';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useArtifacts() {
|
||||
|
|
@ -22,6 +21,7 @@ export default function useArtifacts() {
|
|||
);
|
||||
}, [artifacts]);
|
||||
|
||||
const prevIsSubmittingRef = useRef<boolean>(false);
|
||||
const lastContentRef = useRef<string | null>(null);
|
||||
const hasEnclosedArtifactRef = useRef<boolean>(false);
|
||||
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
|
||||
|
|
@ -36,6 +36,7 @@ export default function useArtifacts() {
|
|||
lastRunMessageIdRef.current = null;
|
||||
lastContentRef.current = null;
|
||||
hasEnclosedArtifactRef.current = false;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
};
|
||||
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
|
||||
resetState();
|
||||
|
|
@ -57,8 +58,17 @@ export default function useArtifacts() {
|
|||
}
|
||||
}, [setCurrentArtifactId, orderedArtifactIds]);
|
||||
|
||||
/**
|
||||
* Manage artifact selection and code tab switching for non-enclosed artifacts
|
||||
* Runs when artifact content changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isSubmitting) {
|
||||
// Check if we just finished submitting (transition from true to false)
|
||||
const justFinishedSubmitting = prevIsSubmittingRef.current && !isSubmitting;
|
||||
prevIsSubmittingRef.current = isSubmitting;
|
||||
|
||||
// Only process during submission OR when just finished
|
||||
if (!isSubmitting && !justFinishedSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (orderedArtifactIds.length === 0) {
|
||||
|
|
@ -69,23 +79,15 @@ export default function useArtifacts() {
|
|||
}
|
||||
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
|
||||
const latestArtifact = artifacts?.[latestArtifactId];
|
||||
if (latestArtifact?.content === lastContentRef.current) {
|
||||
if (latestArtifact?.content === lastContentRef.current && !justFinishedSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentArtifactId(latestArtifactId);
|
||||
lastContentRef.current = latestArtifact?.content ?? null;
|
||||
|
||||
const hasEnclosedArtifact =
|
||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||
latestMessageText.trim(),
|
||||
);
|
||||
|
||||
if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) {
|
||||
setActiveTab('preview');
|
||||
hasEnclosedArtifactRef.current = true;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
} else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
||||
// Only switch to code tab if we haven't detected an enclosed artifact yet
|
||||
if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
||||
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
|
||||
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
|
||||
setActiveTab('code');
|
||||
|
|
@ -101,6 +103,28 @@ export default function useArtifacts() {
|
|||
setCurrentArtifactId,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Watch for enclosed artifact pattern during message generation
|
||||
* Optimized: Exits early if already detected, only checks during streaming
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isSubmitting || hasEnclosedArtifactRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEnclosedArtifact =
|
||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||
latestMessageText.trim(),
|
||||
);
|
||||
|
||||
if (hasEnclosedArtifact) {
|
||||
logger.log('artifacts', 'Enclosed artifact detected during generation, switching to preview');
|
||||
setActiveTab('preview');
|
||||
hasEnclosedArtifactRef.current = true;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
}
|
||||
}, [isSubmitting, latestMessageText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMessageId !== lastRunMessageIdRef.current) {
|
||||
lastRunMessageIdRef.current = latestMessageId;
|
||||
|
|
@ -112,22 +136,13 @@ export default function useArtifacts() {
|
|||
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
|
||||
|
||||
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
|
||||
const cycleArtifact = (direction: 'next' | 'prev') => {
|
||||
let newIndex: number;
|
||||
if (direction === 'next') {
|
||||
newIndex = (currentIndex + 1) % orderedArtifactIds.length;
|
||||
} else {
|
||||
newIndex = (currentIndex - 1 + orderedArtifactIds.length) % orderedArtifactIds.length;
|
||||
}
|
||||
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
||||
};
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue