mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🗃️ refactor: Keep Code Artifacts Manual-Open (#12961)
This commit is contained in:
parent
9efd61d57d
commit
16a65b67fc
7 changed files with 177 additions and 33 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue