🗃️ refactor: Keep Code Artifacts Manual-Open (#12961)

This commit is contained in:
Danny Avila 2026-05-05 22:05:21 -04:00 committed by GitHub
parent 9efd61d57d
commit 16a65b67fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 177 additions and 33 deletions

View file

@ -11,7 +11,7 @@ import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { isPreviewOnlyArtifact } from '~/utils/artifacts';
import { isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts';
import { displayFilename } from '~/components/Chat/Messages/Content/Parts/attachmentTypes';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -93,32 +93,35 @@ export default function Artifacts() {
setCurrentArtifactId,
} = useArtifacts();
/* Office artifacts (DOCX/SPREADSHEET/PRESENTATION) have no source view
* the underlying file is binary and the "code" tab would display the
* generated HTML blob, which isn't useful. Filter the tab options and
* snap the active tab when the user lands on an office artifact while
* the code tab is selected. */
/* Office artifacts have no source view, and source-code artifacts have
* no useful rendered preview. Filter each down to the only meaningful
* tab and label that tab with the file name instead of generic
* "Code" / "Preview" choices. */
const isPreviewOnly = isPreviewOnlyArtifact(currentArtifact?.type);
const isCodeOnly = isCodeOnlyArtifact(currentArtifact?.type);
let constrainedTab: 'preview' | 'code' | null = null;
if (isPreviewOnly) {
constrainedTab = 'preview';
} else if (isCodeOnly) {
constrainedTab = 'code';
}
const displayedTab = constrainedTab ?? activeTab;
const tabOptions = useMemo(() => {
if (!isPreviewOnly) {
if (constrainedTab == null) {
return allTabOptions;
}
/* When only the preview tab is shown, the generic "Preview" label is
* a no-op pill surface the document filename there instead. The
* Play icon stays as a visual cue for "rendered preview". `displayFilename`
* handles the sandbox dotfile suffix the upload pipeline applies. */
const filename = displayFilename(currentArtifact?.title);
const previewTab = allTabOptions.find((opt) => opt.value === 'preview');
if (!previewTab) {
const tab = allTabOptions.find((opt) => opt.value === constrainedTab);
if (!tab) {
return allTabOptions;
}
return [filename ? { ...previewTab, label: filename } : previewTab];
}, [allTabOptions, isPreviewOnly, currentArtifact?.title]);
return [filename ? { ...tab, label: filename } : tab];
}, [allTabOptions, constrainedTab, currentArtifact?.title]);
useEffect(() => {
if (isPreviewOnly && activeTab === 'code') {
setActiveTab('preview');
if (constrainedTab != null && activeTab !== constrainedTab) {
setActiveTab(constrainedTab);
}
}, [isPreviewOnly, activeTab, setActiveTab]);
}, [constrainedTab, activeTab, setActiveTab]);
const handleCopyArtifact = useCallback(() => {
const content = currentArtifact?.content ?? '';
@ -204,7 +207,7 @@ export default function Artifacts() {
: 0;
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
<Tabs.Root value={displayedTab} onValueChange={setActiveTab} asChild>
<div className="flex h-full w-full flex-col">
{/* Mobile backdrop with dynamic blur */}
{isMobile && (
@ -275,9 +278,9 @@ export default function Artifacts() {
>
<Radio
options={tabOptions}
value={activeTab}
value={displayedTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
disabled={isMutating && displayedTab !== 'code'}
buttonClassName="h-9 px-3 gap-1.5"
/>
</div>
@ -290,7 +293,7 @@ export default function Artifacts() {
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
)}
>
{activeTab === 'preview' && (
{displayedTab === 'preview' && (
<Button
size="icon"
variant="ghost"
@ -310,7 +313,7 @@ export default function Artifacts() {
)}
</Button>
)}
{activeTab !== 'preview' && isMutating && (
{displayedTab !== 'preview' && isMutating && (
<RefreshCw size={16} className="animate-spin text-text-secondary" />
)}
{orderedArtifactIds.length > 1 && (
@ -372,9 +375,9 @@ export default function Artifacts() {
<Radio
fullWidth
options={tabOptions}
value={activeTab}
value={displayedTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
disabled={isMutating && displayedTab !== 'code'}
/>
</div>
)}

View file

@ -10,7 +10,7 @@ import {
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import { isCodeOnlyArtifact } from '~/utils/artifacts';
import { displayFilename } from './attachmentTypes';
import { useAttachmentLink } from './LogLink';
import { useLocalize } from '~/hooks';
@ -141,7 +141,7 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)
// visibility alone so the side panel doesn't auto-open on navigation.
return;
}
if (artifact.type === TOOL_ARTIFACT_TYPES.CODE) {
if (isCodeOnlyArtifact(artifact.type)) {
// Source-code artifacts (`.py`, `.js`, `.cpp`, `Dockerfile`, …) are
// click-to-open only. They're typically supporting scripts the
// agent emits alongside a richer deliverable; auto-opening them

View file

@ -10,6 +10,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { ArtifactsContextValue } from '~/Providers';
import { ArtifactsProvider, EditorProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { isCodeOnlyArtifact } from '~/utils/artifacts';
import { getLatestText } from '~/utils';
import store from '~/store';
@ -55,6 +56,7 @@ export function ShareArtifactsContainer({
}: ShareArtifactsContainerProps) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const isSmallScreen = useMediaQuery('(max-width: 1023px)');
const [artifactPanelSize, setArtifactPanelSize] = useState(getInitialArtifactPanelSize);
@ -76,10 +78,14 @@ export function ShareArtifactsContainer({
};
}, [messages, conversationId]);
const hasSelectedArtifact = currentArtifactId != null && artifacts?.[currentArtifactId] != null;
const hasAutoOpenableArtifact = Object.values(artifacts ?? {}).some(
(artifact) => artifact != null && !isCodeOnlyArtifact(artifact.type),
);
const shouldRenderArtifacts =
artifactsVisibility === true &&
artifactsContextValue != null &&
Object.keys(artifacts ?? {}).length > 0;
(hasSelectedArtifact || hasAutoOpenableArtifact);
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));

View file

@ -129,6 +129,25 @@ describe('useArtifacts', () => {
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
});
it('should automatically select the latest non-code artifact when a code file is newest', () => {
const artifacts = {
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
'artifact-2': createArtifact({
id: 'artifact-2',
type: 'application/vnd.code',
title: 'helper.py',
lastUpdateTime: 2000,
}),
};
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
renderHook(() => useArtifacts());
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
expect(mockSetCurrentArtifactId).not.toHaveBeenCalledWith('artifact-2');
});
});
describe('tab switching - enclosed artifacts', () => {
@ -420,6 +439,53 @@ describe('useArtifacts', () => {
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
});
it('should not advance to a new CODE artifact during streaming', () => {
const artifact1 = createArtifact({ id: 'artifact-1', lastUpdateTime: 1000, content: 'c1' });
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageId: 'msg-1',
});
const { rerender } = renderHook(() => useArtifacts());
mockSetCurrentArtifactId.mockClear();
const artifact2 = createArtifact({
id: 'artifact-2',
type: 'application/vnd.code',
title: 'helper.py',
lastUpdateTime: 2000,
content: 'print("hi")',
});
(useRecoilValue as jest.Mock).mockReturnValue({
'artifact-1': artifact1,
'artifact-2': artifact2,
});
rerender();
expect(mockSetCurrentArtifactId).not.toHaveBeenCalledWith('artifact-2');
});
it('should keep a manually selected CODE artifact selected', () => {
const artifact = createArtifact({
id: 'artifact-1',
type: 'application/vnd.code',
title: 'helper.py',
});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
const { result } = renderHook(() => useArtifacts());
expect(result.current.currentArtifact).toBe(artifact);
expect(mockSetCurrentArtifactId).not.toHaveBeenCalledWith('artifact-1');
});
it('should keep selection null after an explicit reset', () => {
const artifact1 = createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 });
@ -691,6 +757,26 @@ describe('useArtifacts', () => {
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
});
it('should not auto-select an idle CODE artifact', () => {
const artifact = createArtifact({
id: 'artifact-1',
type: 'application/vnd.code',
title: 'helper.py',
});
(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());
expect(mockSetCurrentArtifactId).not.toHaveBeenCalled();
});
it('should not process when latestMessageId is null', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });

View file

@ -2,6 +2,7 @@ import { useMemo, useState, useEffect, useRef } from 'react';
import { Constants } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
import { useArtifactsContext } from '~/Providers';
import { isCodeOnlyArtifact } from '~/utils/artifacts';
import { logger } from '~/utils';
import store from '~/store';
@ -15,10 +16,17 @@ export default function useArtifacts() {
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
const orderedArtifactIds = useMemo(() => {
return Object.keys(artifacts ?? {}).sort(
const { orderedArtifactIds, latestAutoOpenArtifactId } = useMemo(() => {
const ids = Object.keys(artifacts ?? {}).sort(
(a, b) => (artifacts?.[a]?.lastUpdateTime ?? 0) - (artifacts?.[b]?.lastUpdateTime ?? 0),
);
for (let i = ids.length - 1; i >= 0; i--) {
const id = ids[i];
if (!isCodeOnlyArtifact(artifacts?.[id]?.type)) {
return { orderedArtifactIds: ids, latestAutoOpenArtifactId: id };
}
}
return { orderedArtifactIds: ids, latestAutoOpenArtifactId: null };
}, [artifacts]);
const prevIsSubmittingRef = useRef<boolean>(false);
@ -62,8 +70,14 @@ export default function useArtifacts() {
if (orderedArtifactIds.length === 0) return;
const currentId = currentArtifactIdRef.current;
if (currentId != null && orderedArtifactIds.includes(currentId)) return;
setCurrentArtifactId(orderedArtifactIds[orderedArtifactIds.length - 1]);
}, [orderedArtifactIds, setCurrentArtifactId]);
if (latestAutoOpenArtifactId == null) {
if (currentId != null) {
resetCurrentArtifactId();
}
return;
}
setCurrentArtifactId(latestAutoOpenArtifactId);
}, [latestAutoOpenArtifactId, orderedArtifactIds, resetCurrentArtifactId, setCurrentArtifactId]);
/**
* Manage artifact selection and code tab switching for non-enclosed artifacts
@ -89,9 +103,12 @@ export default function useArtifacts() {
if (latestArtifact?.content === lastContentRef.current && !justFinishedSubmitting) {
return;
}
lastContentRef.current = latestArtifact?.content ?? null;
if (isCodeOnlyArtifact(latestArtifact?.type)) {
return;
}
setCurrentArtifactId(latestArtifactId);
lastContentRef.current = latestArtifact?.content ?? null;
// Only switch to code tab if we haven't detected an enclosed artifact yet
if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {

View file

@ -2,6 +2,7 @@ import {
buildSandpackOptions,
detectArtifactTypeFromFile,
fileToArtifact,
isCodeOnlyArtifact,
isPreviewOnlyArtifact,
languageForFilename,
TOOL_ARTIFACT_TYPES,
@ -888,3 +889,26 @@ describe('isPreviewOnlyArtifact', () => {
},
);
});
describe('isCodeOnlyArtifact', () => {
it.each([
[TOOL_ARTIFACT_TYPES.CODE, true],
[TOOL_ARTIFACT_TYPES.HTML, false],
[TOOL_ARTIFACT_TYPES.REACT, false],
[TOOL_ARTIFACT_TYPES.MARKDOWN, false],
[TOOL_ARTIFACT_TYPES.MERMAID, false],
[TOOL_ARTIFACT_TYPES.PLAIN_TEXT, false],
[TOOL_ARTIFACT_TYPES.DOCX, false],
[TOOL_ARTIFACT_TYPES.SPREADSHEET, false],
[TOOL_ARTIFACT_TYPES.PRESENTATION, false],
])('type %s returns %s', (type, expected) => {
expect(isCodeOnlyArtifact(type)).toBe(expected);
});
it.each([[null], [undefined], [''], ['application/pdf'], ['text/plain'], ['some/random-type']])(
'returns false for non-code-only type %s',
(type) => {
expect(isCodeOnlyArtifact(type)).toBe(false);
},
);
});

View file

@ -320,6 +320,14 @@ export function isPreviewOnlyArtifact(type: string | null | undefined): boolean
return PREVIEW_ONLY_ARTIFACT_TYPES.has(type as ToolArtifactType);
}
/**
* Source-code files have no useful rendered preview in the artifacts panel.
* They should stay click-to-open and, once opened, expose only the code view.
*/
export function isCodeOnlyArtifact(type: string | null | undefined): boolean {
return type === TOOL_ARTIFACT_TYPES.CODE;
}
/**
* Extension fenced-code-block language hint for the CODE bucket. The
* key is the lowercased file extension (no dot); the value is the