feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

*  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:
Danny Avila 2025-11-12 13:32:47 -05:00 committed by GitHub
parent 4186db3ce2
commit b8b1217c34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1565 additions and 345 deletions

View 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');
});
});
});

View file

@ -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,
};
}