diff --git a/client/src/Providers/CodeBlockContext.tsx b/client/src/Providers/CodeBlockContext.tsx index ad2ffe7350..c7c2d607ba 100644 --- a/client/src/Providers/CodeBlockContext.tsx +++ b/client/src/Providers/CodeBlockContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, ReactNode, useCallback, useRef } from 'react type TCodeBlockContext = { getNextIndex: (skip: boolean) => number; + getNextMermaidIndex: () => number; resetCounter: () => void; }; @@ -11,6 +12,7 @@ export const useCodeBlockContext = () => useContext(CodeBlockContext); export function CodeBlockProvider({ children, baseIndex = 0, + mermaidBaseIndex = 0, }: { children: ReactNode; /** @@ -21,8 +23,14 @@ export function CodeBlockProvider({ * counter. */ baseIndex?: number; + /** + * The same offset for mermaid fences, which are skipped by the code-block + * counter and so need their own sequence to stay distinct from one another. + */ + mermaidBaseIndex?: number; }) { const counterRef = useRef(0); + const mermaidCounterRef = useRef(0); const getNextIndex = useCallback( (skip: boolean) => { @@ -36,12 +44,22 @@ export function CodeBlockProvider({ [baseIndex], ); + const getNextMermaidIndex = useCallback(() => { + const nextIndex = mermaidCounterRef.current; + mermaidCounterRef.current += 1; + return mermaidBaseIndex + nextIndex; + }, [mermaidBaseIndex]); + + /* Both counters restart together. A streamed block re-renders its fences on + * every token, so restarting is what keeps a diagram's index tied to its + * position in the document instead of drifting upward as the message grows. */ const resetCounter = useCallback(() => { counterRef.current = 0; + mermaidCounterRef.current = 0; }, []); return ( - + {children} ); diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts index 3630ac3f24..a47ffb31b7 100644 --- a/client/src/common/artifacts.ts +++ b/client/src/common/artifacts.ts @@ -4,6 +4,8 @@ export interface CodeBlock { content: string; } +export const MERMAID_ARTIFACT_TYPE = 'application/vnd.mermaid' as const; + /** * Original-file download metadata for artifacts backed by a real * code-interpreter file (e.g. an office document whose panel preview is diff --git a/client/src/components/Artifacts/ArtifactTabs.test.tsx b/client/src/components/Artifacts/ArtifactTabs.test.tsx new file mode 100644 index 0000000000..a8c207c719 --- /dev/null +++ b/client/src/components/Artifacts/ArtifactTabs.test.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { render, waitFor } from '@testing-library/react'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { Artifact } from '~/common'; +import ArtifactTabs from './ArtifactTabs'; + +interface EditorProps { + artifact: Artifact; + readOnly?: boolean; +} + +const mockEditor = jest.fn((_props: EditorProps) => null); +const mockUseGetStartupConfig = jest.fn((_options?: unknown) => ({ data: {} })); +const mockUseGetSharedStartupConfig = jest.fn((_shareId?: unknown, _options?: unknown) => ({ + data: {}, +})); +let mockCurrentCode: string | undefined; + +jest.mock('./ArtifactCodeEditor', () => ({ + ArtifactCodeEditor: (props: EditorProps) => mockEditor(props), +})); + +jest.mock('./ArtifactPreview', () => { + const testGlobal = globalThis as typeof globalThis & { + artifactPreviewModuleEvaluations?: number; + }; + testGlobal.artifactPreviewModuleEvaluations = + (testGlobal.artifactPreviewModuleEvaluations ?? 0) + 1; + return { ArtifactPreview: () => null }; +}); + +jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => { + const ReactModule = jest.requireActual('react'); + let mounts = 0; + /** Renders the mount ordinal so a remount (new key) is observable. */ + const nativeRenderer = jest.fn(() => { + const [instance] = ReactModule.useState(() => { + mounts += 1; + return mounts; + }); + return ReactModule.createElement('div', { + 'data-testid': 'mermaid-renderer', + 'data-instance': String(instance), + }); + }); + const testGlobal = globalThis as typeof globalThis & { + nativeMermaidRenderer?: typeof nativeRenderer; + }; + testGlobal.nativeMermaidRenderer = nativeRenderer; + return { MermaidRenderer: nativeRenderer }; +}); + +jest.mock('~/Providers/EditorContext', () => ({ + useCodeState: () => ({ currentCode: mockCurrentCode, setCurrentCode: jest.fn() }), +})); + +jest.mock('~/Providers', () => ({ + useShareContext: () => ({ shareId: undefined }), +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: (options: unknown) => mockUseGetStartupConfig(options), + useGetSharedStartupConfig: (shareId: unknown, options: unknown) => + mockUseGetSharedStartupConfig(shareId, options), +})); + +jest.mock('~/hooks/Artifacts/useArtifactProps', () => ({ + __esModule: true, + default: () => ({ files: {}, fileKey: 'diagram.mmd', template: 'static', sharedProps: {} }), +})); + +const preview: SandpackPreviewRef = Object.create(null); +const previewRef: React.MutableRefObject = { current: preview }; + +function renderArtifact(artifact: Artifact, activeTab: 'code' | 'preview' = 'code') { + return render( + + + , + ); +} + +describe('ArtifactTabs Mermaid editing', () => { + beforeEach(() => { + mockEditor.mockClear(); + mockUseGetStartupConfig.mockClear(); + mockUseGetSharedStartupConfig.mockClear(); + mockCurrentCode = undefined; + }); + + it('renders Mermaid natively without loading startup config or Sandpack preview', () => { + const testGlobal = globalThis as typeof globalThis & { + artifactPreviewModuleEvaluations?: number; + nativeMermaidRenderer?: jest.Mock; + }; + + renderArtifact( + { + id: 'mermaid-chat-1', + type: 'application/vnd.mermaid', + title: 'Flow chart', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }, + 'preview', + ); + + expect(mockUseGetStartupConfig).not.toHaveBeenCalled(); + expect(mockUseGetSharedStartupConfig).not.toHaveBeenCalled(); + expect(testGlobal.artifactPreviewModuleEvaluations ?? 0).toBe(0); + expect(testGlobal.nativeMermaidRenderer?.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + children: 'graph TD\nA-->B', + exportFilename: 'Flow chart', + fillContainer: true, + showExpandButton: false, + showHeader: false, + }), + ); + }); + + it('previews the current Mermaid editor content', () => { + const testGlobal = globalThis as typeof globalThis & { + nativeMermaidRenderer?: jest.Mock; + }; + const artifact: Artifact = { + id: 'mermaid-persisted-1', + type: 'application/vnd.mermaid', + title: 'Flow chart', + content: 'graph TD\nA-->B', + index: 0, + lastUpdateTime: 1, + }; + + const { rerender } = render( + + + , + ); + + /* Editor text only belongs to the preview once it was typed against the + * artifact on screen, so it is applied on a later render, not on mount. */ + mockCurrentCode = 'graph TD\nA-->C'; + rerender( + + + , + ); + + expect(testGlobal.nativeMermaidRenderer?.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ children: 'graph TD\nA-->C' }), + ); + }); + + it('makes chat Mermaid Artifacts read-only when they have no persisted edit target', () => { + renderArtifact({ + id: 'mermaid-chat-1', + type: 'application/vnd.mermaid', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }); + + expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })); + }); + + it('keeps persisted Mermaid Artifacts editable', () => { + renderArtifact({ + id: 'mermaid-persisted-1', + type: 'application/vnd.mermaid', + content: 'graph TD\nA-->B', + index: 0, + messageId: 'message-1', + lastUpdateTime: 1, + }); + + expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: false })); + }); + + it('remounts the renderer when switching between Mermaid Artifacts', () => { + const first: Artifact = { + id: 'mermaid-a', + type: 'application/vnd.mermaid', + title: 'First', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }; + const second: Artifact = { + id: 'mermaid-b', + type: 'application/vnd.mermaid', + title: 'Second', + content: 'graph TD\nC-->D', + lastUpdateTime: 2, + }; + + const { rerender, getByTestId } = render( + + + , + ); + const initialInstance = getByTestId('mermaid-renderer').getAttribute('data-instance'); + + rerender( + + + , + ); + + expect(getByTestId('mermaid-renderer').getAttribute('data-instance')).not.toBe(initialInstance); + }); + + it('does not seed the next diagram with the previous artifact editor text', () => { + const testGlobal = globalThis as typeof globalThis & { + nativeMermaidRenderer?: jest.Mock; + }; + const first: Artifact = { + id: 'mermaid-a', + type: 'application/vnd.mermaid', + title: 'First', + content: 'graph TD\nA-->B', + index: 0, + lastUpdateTime: 1, + }; + const second: Artifact = { + id: 'mermaid-b', + type: 'application/vnd.mermaid', + title: 'Second', + content: 'graph TD\nC-->D', + index: 1, + lastUpdateTime: 2, + }; + + mockCurrentCode = 'graph TD\nEDITED-->A'; + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + const renderedContent = testGlobal.nativeMermaidRenderer?.mock.calls.map( + (call) => (call[0] as { children: string }).children, + ); + expect(renderedContent).not.toContain('graph TD\nEDITED-->A'); + expect(testGlobal.nativeMermaidRenderer?.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ children: 'graph TD\nC-->D' }), + ); + }); + + it('does not mount the Mermaid renderer on the code tab', () => { + const testGlobal = globalThis as typeof globalThis & { + nativeMermaidRenderer?: jest.Mock; + }; + testGlobal.nativeMermaidRenderer?.mockClear(); + + renderArtifact( + { + id: 'mermaid-a', + type: 'application/vnd.mermaid', + title: 'First', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }, + 'code', + ); + + expect(testGlobal.nativeMermaidRenderer).not.toHaveBeenCalled(); + }); + + it('does not remount the renderer while the same Artifact is edited', () => { + const artifact: Artifact = { + id: 'mermaid-a', + type: 'application/vnd.mermaid', + title: 'First', + content: 'graph TD\nA-->B', + index: 0, + lastUpdateTime: 1, + }; + + const { rerender, getByTestId } = render( + + + , + ); + const initialInstance = getByTestId('mermaid-renderer').getAttribute('data-instance'); + + mockCurrentCode = 'graph TD\nA-->C'; + rerender( + + + , + ); + + expect(getByTestId('mermaid-renderer').getAttribute('data-instance')).toBe(initialInstance); + }); + + it('keeps non-Mermaid Artifacts on the startup-config and sandbox preview path', async () => { + const testGlobal = globalThis as typeof globalThis & { + artifactPreviewModuleEvaluations?: number; + }; + + renderArtifact( + { + id: 'html-1', + type: 'text/html', + content: '

Hello

', + lastUpdateTime: 1, + }, + 'preview', + ); + + await waitFor(() => expect(mockUseGetStartupConfig).toHaveBeenCalledWith({ enabled: true })); + expect(testGlobal.artifactPreviewModuleEvaluations).toBe(1); + }); +}); diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 3ebc98a366..0d2d2633a6 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -1,44 +1,63 @@ -import { useRef, useEffect } from 'react'; +import { lazy, Suspense, useEffect, useRef } from 'react'; +import { Spinner } from '@librechat/client'; import * as Tabs from '@radix-ui/react-tabs'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { editor } from 'monaco-editor'; -import type { Artifact } from '~/common'; -import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider'; -import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; +import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import { MermaidRenderer } from '~/components/Messages/Content/Mermaid/Mermaid'; +import { MERMAID_ARTIFACT_TYPE, type Artifact } from '~/common/artifacts'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { useCodeState } from '~/Providers/EditorContext'; -import { ArtifactPreview } from './ArtifactPreview'; -import { useShareContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; -export default function ArtifactTabs({ - artifact, - previewRef, - isSharedConvo, -}: { +const SandboxArtifactTabs = lazy(() => import('./SandboxArtifactTabs')); + +interface ArtifactTabsProps { artifact: Artifact; previewRef: React.MutableRefObject; isSharedConvo?: boolean; -}) { + onMermaidExportReady?: (data: ProcessedMermaidSvg | null) => void; +} + +function LoadingArtifactTabs() { + const localize = useLocalize(); + + return ( +
+
+ ); +} + +function MermaidArtifactTabs({ + artifact, + isSharedConvo, + onMermaidExportReady, +}: Omit) { + const localize = useLocalize(); const { currentCode, setCurrentCode } = useCodeState(); - const { shareId } = useShareContext(); - const shouldUseSharedConfig = - isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0; - const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig }); - const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, { - enabled: shouldUseSharedConfig, - }); - const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig; const monacoRef = useRef(null); const lastIdRef = useRef(null); + /* The reset below only lands after commit, so on the render that switches + * artifacts `currentCode` still holds the previous artifact's editor text. + * Ignore it until the reset catches up, or the freshly keyed renderer would + * mount showing (and exporting) the diagram we just navigated away from. */ + const hasCurrentArtifactCode = lastIdRef.current === artifact.id; + useEffect(() => { if (artifact.id !== lastIdRef.current) { setCurrentCode(undefined); } lastIdRef.current = artifact.id; - }, [setCurrentCode, artifact.id]); + }, [artifact.id, setCurrentCode]); - const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + const content = (hasCurrentArtifactCode ? currentCode : undefined) ?? artifact.content ?? ''; + const isReadOnly = isSharedConvo === true || artifact.index == null; return (
@@ -48,24 +67,46 @@ export default function ArtifactTabs({ className="h-full w-full flex-grow overflow-auto" tabIndex={-1} > - + - + {/* Keyed by artifact so switching between two diagrams cannot carry the + previous render, its dimensions, or its export payload across the + boundary while the new source debounces. */} + + {content} +
); } + +export default function ArtifactTabs(props: ArtifactTabsProps) { + if (props.artifact.type === MERMAID_ARTIFACT_TYPE) { + return ( + + ); + } + + return ( + }> + + + ); +} diff --git a/client/src/components/Artifacts/Artifacts.test.tsx b/client/src/components/Artifacts/Artifacts.test.tsx new file mode 100644 index 0000000000..49461439a7 --- /dev/null +++ b/client/src/components/Artifacts/Artifacts.test.tsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import Artifacts from './Artifacts'; +import store from '~/store'; + +const mockUseArtifacts = jest.fn(); +let mockIsMobile = false; +let mockPrefersReducedMotion = false; + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useMediaQuery: (query: string) => + query === '(prefers-reduced-motion: reduce)' ? mockPrefersReducedMotion : mockIsMobile, +})); + +jest.mock('~/Providers', () => ({ + useMutationState: () => ({ isMutating: false }), + useShareContext: () => ({ isSharedConvo: false }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string): string => + key, + useFocusTrap: ( + containerRef: React.RefObject, + active: boolean, + onEscape?: () => void, + ) => { + const ReactModule = jest.requireActual('react'); + ReactModule.useEffect(() => { + if (!active) { + return; + } + const container = containerRef.current; + const firstFocusable = container?.querySelector('button, [tabindex="0"]'); + firstFocusable?.focus(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscape?.(); + } + }; + container?.addEventListener('keydown', handleKeyDown); + return () => container?.removeEventListener('keydown', handleKeyDown); + }, [active, containerRef, onEscape]); + }, +})); + +jest.mock('~/hooks/Artifacts/useArtifacts', () => ({ + __esModule: true, + default: () => mockUseArtifacts(), +})); + +jest.mock('./ArtifactTabs', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('./ArtifactVersion', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('./DownloadArtifact', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('./Mermaid/Export', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Messages/Content/CopyButton', () => ({ + __esModule: true, + default: () => null, +})); + +const ArtifactStateProbe = () => { + const currentArtifactId = useRecoilValue(store.currentArtifactId); + const isVisible = useRecoilValue(store.artifactsVisibility); + return ( + + ); +}; + +describe('Artifacts panel accessibility', () => { + beforeEach(() => { + mockIsMobile = false; + mockPrefersReducedMotion = false; + mockUseArtifacts.mockReturnValue({ + activeTab: 'code', + setActiveTab: jest.fn(), + currentIndex: 0, + currentArtifact: { + id: 'mermaid-artifact-1', + type: 'application/vnd.mermaid', + title: 'Diagram', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }, + orderedArtifactIds: ['mermaid-artifact-1'], + setCurrentArtifactId: jest.fn(), + }); + }); + + it('hides the Sandpack refresh action for Mermaid previews', async () => { + mockUseArtifacts.mockReturnValue({ + activeTab: 'preview', + setActiveTab: jest.fn(), + currentIndex: 0, + currentArtifact: { + id: 'mermaid-artifact-1', + type: 'application/vnd.mermaid', + title: 'Diagram', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }, + orderedArtifactIds: ['mermaid-artifact-1'], + setCurrentArtifactId: jest.fn(), + }); + + render( + + + , + ); + + await screen.findByRole('region', { name: 'Diagram' }); + expect(screen.queryByRole('button', { name: 'com_ui_refresh' })).not.toBeInTheDocument(); + expect(screen.getByTestId('mermaid-export')).toBeInTheDocument(); + }); + + it('hides the Mermaid export action outside the preview tab', async () => { + render( + + + , + ); + + await screen.findByRole('region', { name: 'Diagram' }); + expect(screen.queryByTestId('mermaid-export')).not.toBeInTheDocument(); + }); + + it('keeps the refresh action for sandboxed previews', async () => { + mockUseArtifacts.mockReturnValue({ + activeTab: 'preview', + setActiveTab: jest.fn(), + currentIndex: 0, + currentArtifact: { + id: 'html-artifact-1', + type: 'text/html', + title: 'Page', + content: '

Hi

', + lastUpdateTime: 1, + }, + orderedArtifactIds: ['html-artifact-1'], + setCurrentArtifactId: jest.fn(), + }); + + render( + + + , + ); + + await screen.findByRole('region', { name: 'Page' }); + expect(screen.getByRole('button', { name: 'com_ui_refresh' })).toBeInTheDocument(); + }); + + it('keeps the resizable layout ID distinct from the controlled Artifact region', async () => { + const { container } = render( + +
+ +
+
, + ); + + await screen.findByRole('region', { name: 'Diagram' }); + + expect(container.querySelectorAll('#artifacts-panel')).toHaveLength(1); + expect(container.querySelectorAll('#artifact-viewer')).toHaveLength(1); + }); + + it('supports keyboard resizing and restores focus after the mobile sheet closes', async () => { + mockIsMobile = true; + const opener = document.createElement('button'); + opener.textContent = 'Open artifact'; + document.body.appendChild(opener); + opener.focus(); + + render( + + + , + ); + + const dialog = await screen.findByRole('dialog', { name: 'Diagram' }); + const separator = screen.getByRole('separator', { name: 'com_ui_resize_artifact_panel' }); + await waitFor(() => expect(separator).toHaveFocus()); + + fireEvent.keyDown(separator, { key: 'ArrowDown' }); + expect(separator).toHaveAttribute('aria-valuenow', '80'); + expect(dialog).toHaveStyle({ height: '80vh' }); + + fireEvent.keyDown(separator, { key: 'Home' }); + expect(separator).toHaveAttribute('aria-valuenow', '10'); + expect(dialog).toHaveStyle({ height: '10vh' }); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' })); + expect(opener).not.toHaveFocus(); + await waitFor(() => expect(opener).toHaveFocus()); + + opener.remove(); + }); + + it('closes without the animation delay when reduced motion is preferred', async () => { + mockIsMobile = true; + mockPrefersReducedMotion = true; + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + + render( + { + set(store.currentArtifactId, 'mermaid-artifact-1'); + set(store.artifactsVisibility, true); + }} + > + + + , + ); + + const separator = await screen.findByRole('separator', { + name: 'com_ui_resize_artifact_panel', + }); + await waitFor(() => expect(separator).toHaveFocus()); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' })); + + expect(screen.getByTestId('artifact-state')).toHaveAttribute('data-current-id', ''); + expect(screen.getByTestId('artifact-state')).toHaveAttribute('data-visible', 'false'); + await waitFor(() => expect(opener).toHaveFocus()); + + opener.remove(); + }); +}); diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index f9bdbd888d..f72494f87e 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -5,15 +5,17 @@ import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import { Code, Maximize2, Minimize2, Play, RefreshCw, X } from 'lucide-react'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react'; +import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import { TOOL_ARTIFACT_TYPES, isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts'; import { displayFilename } from '~/components/Chat/Messages/Content/Parts/attachmentTypes'; -import { isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts'; import CopyButton from '~/components/Messages/Content/CopyButton'; import { useShareContext, useMutationState } from '~/Providers'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; +import { useFocusTrap, useLocalize } from '~/hooks'; import DownloadArtifact from './DownloadArtifact'; import ArtifactVersion from './ArtifactVersion'; +import MermaidExport from './Mermaid/Export'; import ArtifactTabs from './ArtifactTabs'; -import { useLocalize } from '~/hooks'; import { cn, logger } from '~/utils'; import store from '~/store'; @@ -25,9 +27,12 @@ export default function Artifacts() { const { isMutating } = useMutationState(); const { isSharedConvo } = useShareContext(); const isMobile = useMediaQuery('(max-width: 868px)'); + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const previewRef = useRef(); const artifactContainerRef = useRef(null); const fullscreenPortalRef = useRef(null); + const panelRef = useRef(null); + const openerRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -37,6 +42,10 @@ export default function Artifacts() { const [isDragging, setIsDragging] = useState(false); const [blurAmount, setBlurAmount] = useState(0); const [isCopied, setIsCopied] = useState(false); + const [mermaidExportState, setMermaidExportState] = useState<{ + artifactId: string; + data: ProcessedMermaidSvg | null; + } | null>(null); const dragStartY = useRef(0); const dragStartHeight = useRef(90); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); @@ -59,6 +68,10 @@ export default function Artifacts() { ); useEffect(() => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && activeElement !== document.body) { + openerRef.current = activeElement; + } setIsMounted(true); const delay = isMobile ? 50 : 30; const timer = setTimeout(() => setIsVisible(true), delay); @@ -106,6 +119,70 @@ export default function Artifacts() { setCurrentArtifactId, } = useArtifacts(); + const restoreArtifactTriggerFocus = useCallback(() => { + const opener = openerRef.current; + const artifactId = currentArtifact?.id; + requestAnimationFrame(() => { + if (opener?.isConnected) { + opener.focus(); + return; + } + + const trigger = Array.from( + document.querySelectorAll('[data-artifact-trigger]'), + ).find((element) => element.dataset.artifactTrigger === artifactId); + trigger?.focus(); + }); + }, [currentArtifact?.id]); + + const handleMermaidExportReady = useCallback( + (data: ProcessedMermaidSvg | null) => { + if (currentArtifact?.id == null) { + return; + } + setMermaidExportState({ artifactId: currentArtifact.id, data }); + }, + [currentArtifact?.id], + ); + + const mermaidExportData = + mermaidExportState != null && mermaidExportState.artifactId === currentArtifact?.id + ? mermaidExportState.data + : null; + const isMermaidArtifact = currentArtifact?.type === TOOL_ARTIFACT_TYPES.MERMAID; + + const closeArtifacts = useCallback(() => { + if (isMobile) { + setIsClosing(true); + setIsVisible(false); + const finishClose = () => { + resetCurrentArtifactId(); + setArtifactsVisible(false); + setIsClosing(false); + setHeight(90); + restoreArtifactTriggerFocus(); + }; + if (prefersReducedMotion) { + finishClose(); + } else { + setTimeout(finishClose, 250); + } + return; + } + + resetCurrentArtifactId(); + setArtifactsVisible(false); + restoreArtifactTriggerFocus(); + }, [ + isMobile, + prefersReducedMotion, + resetCurrentArtifactId, + restoreArtifactTriggerFocus, + setArtifactsVisible, + ]); + + useFocusTrap(panelRef, isMobile && isVisible && !isClosing, closeArtifacts); + /* 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 @@ -186,6 +263,24 @@ export default function Artifacts() { } }; + const handleDragKeyDown = (e: React.KeyboardEvent) => { + let nextHeight = height; + if (e.key === 'ArrowUp') { + nextHeight = Math.min(100, height + 10); + } else if (e.key === 'ArrowDown') { + nextHeight = Math.max(10, height - 10); + } else if (e.key === 'Home') { + nextHeight = 10; + } else if (e.key === 'End') { + nextHeight = 100; + } else { + return; + } + + e.preventDefault(); + setHeight(nextHeight); + }; + if (!currentArtifact || !isMounted) { return null; } @@ -216,21 +311,6 @@ export default function Artifacts() { } }; - const closeArtifacts = () => { - if (isMobile) { - setIsClosing(true); - setIsVisible(false); - setTimeout(() => { - setArtifactsVisible(false); - setIsClosing(false); - setHeight(90); - }, 250); - } else { - resetCurrentArtifactId(); - setArtifactsVisible(false); - } - }; - const backdropOpacity = blurAmount > 0 ? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY @@ -243,7 +323,7 @@ export default function Artifacts() { {isMobile && (
)}
{isMobile && !isFullscreen && (
-
+
)} {/* Header */}
{!isMobile && (
- {displayedTab === 'preview' && ( + {/* Refresh drives the Sandpack preview client; the Mermaid + renderer has no such client and offers its own retry, so the + action would spin over an unchanged diagram. */} + {displayedTab === 'preview' && !isMermaidArtifact && (
diff --git a/client/src/components/Artifacts/Mermaid/Export.test.tsx b/client/src/components/Artifacts/Mermaid/Export.test.tsx new file mode 100644 index 0000000000..63a72fe657 --- /dev/null +++ b/client/src/components/Artifacts/Mermaid/Export.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import type { Artifact } from '~/common'; +import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts'; +import MermaidExport from './Export'; + +interface ExportProps { + svg?: string | null; + dimensions?: ProcessedMermaidSvg['dimensions']; + filename: string; + buttonClassName?: string; +} + +const mockExport = jest.fn((_props: ExportProps) => null); + +jest.mock('~/components/Messages/Content/Mermaid/Export', () => ({ + __esModule: true, + default: (props: ExportProps) => mockExport(props), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string): string => + key, +})); + +const artifact: Artifact = { + id: 'tool-artifact-flow chart.mmd', + type: TOOL_ARTIFACT_TYPES.MERMAID, + title: 'flow.mmd', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, +}; + +describe('Artifact Mermaid export', () => { + beforeEach(() => { + mockExport.mockClear(); + }); + + it('reuses the SVG and dimensions already rendered by the Artifact preview', () => { + const exportData: ProcessedMermaidSvg = { + svg: '', + dimensions: { width: 400, height: 200 }, + }; + + render(); + + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ + svg: exportData.svg, + dimensions: exportData.dimensions, + filename: 'flow.mmd', + }), + ); + expect(mockExport.mock.calls[0][0]).not.toHaveProperty('source'); + }); + + it('does not offer export before the preview SVG is ready', () => { + render(); + + expect(mockExport).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Artifacts/Mermaid/Export.tsx b/client/src/components/Artifacts/Mermaid/Export.tsx new file mode 100644 index 0000000000..73958952eb --- /dev/null +++ b/client/src/components/Artifacts/Mermaid/Export.tsx @@ -0,0 +1,36 @@ +import React, { memo } from 'react'; +import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import type { Artifact } from '~/common'; +import MermaidExport from '~/components/Messages/Content/Mermaid/Export'; +import { useLocalize } from '~/hooks'; + +const ArtifactMermaidExport = memo(function ArtifactMermaidExport({ + artifact, + exportData, + portalElement, +}: { + artifact: Artifact; + exportData?: ProcessedMermaidSvg | null; + portalElement?: HTMLElement | null; +}) { + const localize = useLocalize(); + + if (exportData == null) { + return null; + } + + return ( + + ); +}); + +ArtifactMermaidExport.displayName = 'ArtifactMermaidExport'; + +export default ArtifactMermaidExport; diff --git a/client/src/components/Artifacts/SandboxArtifactTabs.tsx b/client/src/components/Artifacts/SandboxArtifactTabs.tsx new file mode 100644 index 0000000000..2fcede516a --- /dev/null +++ b/client/src/components/Artifacts/SandboxArtifactTabs.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { editor } from 'monaco-editor'; +import type { Artifact } from '~/common'; +import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider'; +import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; +import { ArtifactCodeEditor } from './ArtifactCodeEditor'; +import { useCodeState } from '~/Providers/EditorContext'; +import { ArtifactPreview } from './ArtifactPreview'; +import { useShareContext } from '~/Providers'; + +export default function SandboxArtifactTabs({ + artifact, + previewRef, + isSharedConvo, +}: { + artifact: Artifact; + previewRef: React.MutableRefObject; + isSharedConvo?: boolean; +}) { + const { currentCode, setCurrentCode } = useCodeState(); + const { shareId } = useShareContext(); + const shouldUseSharedConfig = + isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0; + const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig }); + const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, { + enabled: shouldUseSharedConfig, + }); + const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig; + const monacoRef = useRef(null); + const lastIdRef = useRef(null); + + /* The reset lands only after commit, so the render that switches artifacts + * still sees the previous artifact's editor text. */ + const hasCurrentArtifactCode = lastIdRef.current === artifact.id; + + useEffect(() => { + if (artifact.id !== lastIdRef.current) { + setCurrentCode(undefined); + } + lastIdRef.current = artifact.id; + }, [artifact.id, setCurrentCode]); + + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx index 89f2fc76bd..469159fc3e 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx @@ -15,6 +15,7 @@ type MarkdownBlockProps = SharedProps & { content: string; codeBaseIndex: number; artifactBaseIndex: number; + mermaidBaseIndex: number; }; /** @@ -29,13 +30,14 @@ const MarkdownBlock = memo( content, codeBaseIndex, artifactBaseIndex, + mermaidBaseIndex, remarkPlugins, rehypePlugins, components, }: MarkdownBlockProps) { return ( - + prev.content === next.content && prev.codeBaseIndex === next.codeBaseIndex && - prev.artifactBaseIndex === next.artifactBaseIndex, + prev.artifactBaseIndex === next.artifactBaseIndex && + prev.mermaidBaseIndex === next.mermaidBaseIndex, ); MarkdownBlock.displayName = 'MarkdownBlock'; @@ -76,10 +79,12 @@ const MarkdownBlocks = memo(function MarkdownBlocks({ const blocks = useMemo(() => { let codeBaseIndex = 0; let artifactBaseIndex = 0; + let mermaidBaseIndex = 0; return splitMarkdownIntoBlocks(content).map((block) => { - const entry = { raw: block.raw, codeBaseIndex, artifactBaseIndex }; + const entry = { raw: block.raw, codeBaseIndex, artifactBaseIndex, mermaidBaseIndex }; codeBaseIndex += block.codeBlockCount; artifactBaseIndex += block.artifactCount; + mermaidBaseIndex += block.mermaidCount; return entry; }); }, [content]); @@ -93,10 +98,11 @@ const MarkdownBlocks = memo(function MarkdownBlocks({ // ref. During append-only streaming these stay constant, so completed // blocks keep a stable key and are not remounted. { resetCounter(); @@ -54,7 +58,7 @@ export const code: React.ElementType = memo(function MarkdownCode({ const content = typeof children === 'string' ? children : String(children); return ( - {content} + {content} ); } else if (isSingleLine) { diff --git a/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx b/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx index e95c049e48..8083190ace 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx @@ -1,9 +1,9 @@ -import { memo, useId, useLayoutEffect } from 'react'; +import { memo, useId, useLayoutEffect, useMemo } from 'react'; import { Download } from 'lucide-react'; import { useRecoilState } from 'recoil'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; +import { fileToArtifact, TOOL_ARTIFACT_TYPES, toolArtifactKey } from '~/utils/artifacts'; import Mermaid from '~/components/Messages/Content/Mermaid/Mermaid'; -import { toolArtifactKey } from '~/utils/artifacts'; import { displayFilename } from './attachmentTypes'; import { useAttachmentLink } from './LogLink'; import { useLocalize } from '~/hooks'; @@ -16,10 +16,9 @@ interface ToolMermaidArtifactProps { } /** - * Renders a code-execution-produced mermaid artifact inline. Skips the - * sandpack/react path the side-panel artifacts use — the standalone - * Mermaid component has its own zoom/expand/code-toggle UI and we want - * to reuse it without bringing the bundler chrome along. + * Renders a code-execution-produced Mermaid artifact inline until the + * user opens it in the Artifact panel. The compact card keeps the file + * available in chat without rendering the same diagram twice. * * Shares the `toolArtifactClaim` dedup atom with `ToolArtifactCard` so * the same `.mmd` file can't double-render across tool calls / messages. @@ -45,6 +44,11 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps user: file.user, source: file.source, }); + const artifact = useMemo( + () => + fileToArtifact({ ...attachment, text }, { preClassifiedType: TOOL_ARTIFACT_TYPES.MERMAID }), + [attachment, text], + ); if (claim != null && !isMyClaim) { return null; @@ -84,7 +88,13 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps )} {/* `id` is optional on Mermaid; pass only when we have a real file_id so the component generates a unique render target on its own. */} - {file.file_id ? {text} : {text}} + {file.file_id ? ( + + {text} + + ) : ( + {text} + )}
); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx index 555c8c6ffe..9772a23a50 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx @@ -45,8 +45,21 @@ jest.mock('~/components/Chat/Messages/Content/Image', () => ({ jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => ({ __esModule: true, - default: ({ children }: { children: string }) => ( -
{children}
+ default: ({ + children, + artifact, + }: { + children: string; + artifact?: { id: string; title?: string; type?: string }; + }) => ( +
+ {children} +
), })); @@ -187,7 +200,11 @@ describe('Attachment routing for tool artifacts', () => { text: 'graph TD\nA-->B', } as Partial); renderWith(); - expect(screen.getByTestId('mermaid-render')).toHaveTextContent('graph TD'); + const renderer = screen.getByTestId('mermaid-render'); + expect(renderer).toHaveTextContent('graph TD'); + expect(renderer).toHaveAttribute('data-artifact-id', 'tool-artifact-file-1'); + expect(renderer).toHaveAttribute('data-artifact-title', 'flow.mmd'); + expect(renderer).toHaveAttribute('data-artifact-type', 'application/vnd.mermaid'); // The card-style trigger should NOT be rendered for mermaid expect(screen.queryByText('com_ui_artifact_click')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/MermaidBlockIds.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/MermaidBlockIds.test.tsx new file mode 100644 index 0000000000..9d31ba34e0 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/MermaidBlockIds.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from '../markdownConfig'; +import MarkdownBlocks from '../MarkdownBlocks'; + +/** + * Mermaid fences do not consume a code-block index, so before they carried + * their own sequence every diagram in a message received the same `mermaid-N` + * id and therefore the same Recoil artifact key. + */ +jest.mock('~/components/Messages/Content/Mermaid', () => ({ + __esModule: true, + default: ({ id, children }: { id?: string; children: string }) => ( +
+ {children} +
+ ), + MermaidErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('~/components/Messages/Content/CodeBlock', () => ({ + __esModule: true, + default: ({ blockIndex }: { blockIndex?: number }) => ( +
+ ), +})); + +const renderMarkdown = (content: string) => + render( + , + ); + +const mermaidIds = () => + screen.getAllByTestId('mermaid').map((element) => element.getAttribute('data-mermaid-id')); + +describe('Mermaid block ids', () => { + it('gives each Mermaid fence in a message a distinct id', () => { + renderMarkdown( + ['```mermaid', 'graph TD', 'A-->B', '```', '', '```mermaid', 'graph TD', 'C-->D', '```'].join( + '\n', + ), + ); + + const ids = mermaidIds(); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); + + it('keeps Mermaid ids distinct across intervening executable code blocks', () => { + renderMarkdown( + [ + '```mermaid', + 'graph TD', + 'A-->B', + '```', + '', + '```python', + 'print("hi")', + '```', + '', + '```mermaid', + 'graph TD', + 'C-->D', + '```', + '', + '```mermaid', + 'graph TD', + 'E-->F', + '```', + ].join('\n'), + ); + + const ids = mermaidIds(); + expect(ids).toHaveLength(3); + expect(new Set(ids).size).toBe(3); + }); + + /** + * Fences nested in one top-level block share a provider and re-run their + * index on every streamed token, so the counter has to restart per render. + * Without that restart the indices climb as the message grows and a diagram + * already open in the panel loses the artifact id it was registered under. + */ + it('holds Mermaid ids steady as a nested block keeps streaming', () => { + const listWithOne = ['- step one', '', ' ```mermaid', ' graph TD', ' A-->B', ' ```'].join( + '\n', + ); + const listWithTwo = [ + listWithOne, + '', + '- step two', + '', + ' ```mermaid', + ' graph TD', + ' C-->D', + ' ```', + ].join('\n'); + const listStillGrowing = [listWithTwo, '', '- step three'].join('\n'); + + const { rerender } = renderMarkdown(listWithOne); + expect(mermaidIds()).toEqual(['mermaid-0']); + + const view = (content: string) => ( + + ); + + rerender(view(listWithTwo)); + expect(mermaidIds()).toEqual(['mermaid-0', 'mermaid-1']); + + rerender(view(listStillGrowing)); + expect(mermaidIds()).toEqual(['mermaid-0', 'mermaid-1']); + }); + + it('does not let a Mermaid fence disturb executable code block indices', () => { + renderMarkdown( + [ + '```python', + 'print("first")', + '```', + '', + '```mermaid', + 'graph TD', + 'A-->B', + '```', + '', + '```python', + 'print("second")', + '```', + ].join('\n'), + ); + + const indices = screen + .getAllByTestId('code-block') + .map((element) => element.getAttribute('data-block-index')); + expect(indices).toEqual(['0', '1']); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/splitMarkdown.ts b/client/src/components/Chat/Messages/Content/splitMarkdown.ts index 1037839c6e..e5dfc9d23d 100644 --- a/client/src/components/Chat/Messages/Content/splitMarkdown.ts +++ b/client/src/components/Chat/Messages/Content/splitMarkdown.ts @@ -13,6 +13,8 @@ export type MarkdownBlock = { codeBlockCount: number; /** Artifact containers within this block. */ artifactCount: number; + /** Mermaid fences within this block, which carry their own index sequence. */ + mermaidCount: number; }; type MdastNode = { @@ -57,7 +59,10 @@ const containsDefinition = (node: MdastNode): boolean => { const ARTIFACT_DIRECTIVE_TYPES = new Set(['containerDirective', 'leafDirective']); -const countWithin = (node: MdastNode, counts: { code: number; artifact: number }): void => { +const countWithin = ( + node: MdastNode, + counts: { code: number; artifact: number; mermaid: number }, +): void => { if (ARTIFACT_DIRECTIVE_TYPES.has(node.type) && node.name === 'artifact') { // artifactPlugin renders container (`:::artifact:::`) and leaf // (`::artifact{}`) artifact directives as an Artifact, each consuming one @@ -68,8 +73,12 @@ const countWithin = (node: MdastNode, counts: { code: number; artifact: number } counts.artifact += 1; return; } - if (node.type === 'code' && isExecutableCode(node.lang ?? '')) { - counts.code += 1; + if (node.type === 'code') { + if (isExecutableCode(node.lang ?? '')) { + counts.code += 1; + } else if (renderedCodeLang(node.lang ?? '') === 'mermaid') { + counts.mermaid += 1; + } } if (node.children) { for (const child of node.children) { @@ -111,7 +120,7 @@ export function splitMarkdownIntoBlocks(content: string): MarkdownBlock[] { const children = tree.children ?? []; if (children.length === 0) { - return [{ raw: content, codeBlockCount: 0, artifactCount: 0 }]; + return [{ raw: content, codeBlockCount: 0, artifactCount: 0, mermaidCount: 0 }]; } // Per-block rendering loses document-global context, so render the whole @@ -136,22 +145,29 @@ export function splitMarkdownIntoBlocks(content: string): MarkdownBlock[] { if (start == null || end == null) { return [{ raw: content, ...blockCounts(children) }]; } - const counts = { code: 0, artifact: 0 }; + const counts = { code: 0, artifact: 0, mermaid: 0 }; countWithin(node, counts); blocks.push({ raw: content.slice(start, end), codeBlockCount: counts.code, artifactCount: counts.artifact, + mermaidCount: counts.mermaid, }); } return blocks; } -const blockCounts = (children: MdastNode[]): { codeBlockCount: number; artifactCount: number } => { - const counts = { code: 0, artifact: 0 }; +const blockCounts = ( + children: MdastNode[], +): { codeBlockCount: number; artifactCount: number; mermaidCount: number } => { + const counts = { code: 0, artifact: 0, mermaid: 0 }; for (const node of children) { countWithin(node, counts); } - return { codeBlockCount: counts.code, artifactCount: counts.artifact }; + return { + codeBlockCount: counts.code, + artifactCount: counts.artifact, + mermaidCount: counts.mermaid, + }; }; diff --git a/client/src/components/Chat/Presentation.test.tsx b/client/src/components/Chat/Presentation.test.tsx new file mode 100644 index 0000000000..ed45513962 --- /dev/null +++ b/client/src/components/Chat/Presentation.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Artifact } from '~/common'; +import Presentation from './Presentation'; +import store from '~/store'; + +const mockArtifactPanelLabel = 'Artifact panel loaded'; +const mockOpenArtifactLabel = 'Open Artifact'; + +jest.mock('~/components/Artifacts/Artifacts', () => { + const artifactPanelLabel = 'Artifact panel loaded'; + const testGlobal = globalThis as typeof globalThis & { + presentationArtifactModuleEvaluations?: number; + }; + testGlobal.presentationArtifactModuleEvaluations = + (testGlobal.presentationArtifactModuleEvaluations ?? 0) + 1; + return { + __esModule: true, + default: () => , + }; +}); + +jest.mock('~/components/Chat/Input/Files/DragDropWrapper', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('~/components/SidePanel', () => ({ + SidePanelGroup: ({ + artifacts, + children, + }: { + artifacts: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {children} + {artifacts} +
+ ), +})); + +jest.mock('~/Providers', () => ({ + ArtifactsProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + EditorProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('~/hooks/Artifacts/useResetArtifactsOnConversationChange', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('~/data-provider', () => ({ + useDeleteFilesMutation: () => ({ mutateAsync: jest.fn() }), +})); + +jest.mock('~/hooks', () => ({ + useSetFilesToDelete: () => jest.fn(), +})); + +const OpenArtifactPanel = () => { + const setArtifacts = useSetRecoilState(store.artifactsState); + const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId); + const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); + + const open = () => { + const artifact: Artifact = { + id: 'mermaid-artifact', + type: 'application/vnd.mermaid', + title: 'Diagram', + content: 'graph TD\nA-->B', + lastUpdateTime: 1, + }; + setArtifacts({ [artifact.id]: artifact }); + setCurrentArtifactId(artifact.id); + setArtifactsVisible(true); + }; + + return ( + + ); +}; + +describe('Presentation Artifact loading', () => { + it('loads the Artifact panel bundle only when the panel is opened', async () => { + const testGlobal = globalThis as typeof globalThis & { + presentationArtifactModuleEvaluations?: number; + }; + + render( + + + + + , + ); + + expect(testGlobal.presentationArtifactModuleEvaluations ?? 0).toBe(0); + + fireEvent.click(screen.getByRole('button', { name: mockOpenArtifactLabel })); + + expect(await screen.findByText(mockArtifactPanelLabel)).toBeInTheDocument(); + expect(testGlobal.presentationArtifactModuleEvaluations).toBe(1); + }); +}); diff --git a/client/src/components/Chat/Presentation.tsx b/client/src/components/Chat/Presentation.tsx index 287e1442bd..7ab0a492b2 100644 --- a/client/src/components/Chat/Presentation.tsx +++ b/client/src/components/Chat/Presentation.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { lazy, Suspense, useEffect, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { FileSources, LocalStorageKeys } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; @@ -6,11 +6,12 @@ import useResetArtifactsOnConversationChange from '~/hooks/Artifacts/useResetArt import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper'; import { EditorProvider, ArtifactsProvider } from '~/Providers'; import { useDeleteFilesMutation } from '~/data-provider'; -import Artifacts from '~/components/Artifacts/Artifacts'; import { SidePanelGroup } from '~/components/SidePanel'; import { useSetFilesToDelete } from '~/hooks'; import store from '~/store'; +const Artifacts = lazy(() => import('~/components/Artifacts/Artifacts')); + export default function Presentation({ children }: { children: React.ReactNode }) { const artifacts = useRecoilValue(store.artifactsState); const artifactsVisibility = useRecoilValue(store.artifactsVisibility); @@ -67,7 +68,9 @@ export default function Presentation({ children }: { children: React.ReactNode } return ( - + + + ); diff --git a/client/src/components/Messages/Content/Mermaid/Export.test.tsx b/client/src/components/Messages/Content/Mermaid/Export.test.tsx new file mode 100644 index 0000000000..a9ab3a8268 --- /dev/null +++ b/client/src/components/Messages/Content/Mermaid/Export.test.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { downloadMermaidPng, downloadMermaidSvg } from '~/utils/diagram/export'; +import MermaidExport from './Export'; + +const mockShowToast = jest.fn(); + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useToastContext: () => ({ showToast: mockShowToast }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string): string => + key, +})); + +jest.mock('~/utils/diagram/export', () => ({ + downloadMermaidPng: jest.fn(), + downloadMermaidSvg: jest.fn(), +})); + +const mockDownloadMermaidPng = jest.mocked(downloadMermaidPng); +const mockDownloadMermaidSvg = jest.mocked(downloadMermaidSvg); + +describe('MermaidExport', () => { + beforeEach(() => { + document.documentElement.style.setProperty('--surface-primary-alt', '23 23 23'); + mockShowToast.mockReset(); + mockDownloadMermaidPng.mockResolvedValue(); + mockDownloadMermaidSvg.mockReset(); + }); + + it('renders the menu inside the fullscreen element when one is given', async () => { + const user = userEvent.setup(); + const fullscreenHost = document.createElement('div'); + document.body.appendChild(fullscreenHost); + + render( + '} + dimensions={{ width: 400, height: 200 }} + filename="flow.mmd" + portalElement={fullscreenHost} + />, + ); + + await user.click(screen.getByRole('button', { name: 'com_ui_export_mermaid' })); + + const menu = await screen.findByRole('menu'); + expect(fullscreenHost.contains(menu)).toBe(true); + + fullscreenHost.remove(); + }); + + it('exports an already-rendered inline diagram as SVG and PNG', async () => { + const user = userEvent.setup(); + render( + '} + dimensions={{ width: 400, height: 200 }} + filename="flow.mmd" + />, + ); + + const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' }); + await user.click(trigger); + expect(await screen.findByRole('menu')).toHaveClass('popover-ui'); + await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_svg' })); + expect(mockDownloadMermaidSvg).toHaveBeenCalledWith( + '', + 'flow.mmd', + 'rgb(23 23 23)', + ); + await waitFor(() => expect(trigger).toHaveFocus()); + + await user.click(trigger); + await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_png' })); + expect(mockDownloadMermaidPng).toHaveBeenCalledWith( + '', + 'flow.mmd', + { width: 400, height: 200 }, + 'rgb(23 23 23)', + ); + }); + + it('keeps both formats disabled until an existing preview SVG is ready', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'com_ui_export_mermaid' })); + + expect(screen.getByRole('menuitem', { name: 'com_ui_export_svg' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('menuitem', { name: 'com_ui_export_png' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(mockDownloadMermaidSvg).not.toHaveBeenCalled(); + expect(mockDownloadMermaidPng).not.toHaveBeenCalled(); + }); + + it('supports keyboard export and restores focus to its trigger', async () => { + const user = userEvent.setup(); + render('} filename="flow.mmd" />); + + await user.tab(); + const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' }); + expect(trigger).toHaveFocus(); + + await user.keyboard('{Enter}'); + const svgItem = await screen.findByRole('menuitem', { name: 'com_ui_export_svg' }); + expect(svgItem).toHaveFocus(); + await user.keyboard('{Enter}'); + + expect(mockDownloadMermaidSvg).toHaveBeenCalledWith( + '', + 'flow.mmd', + 'rgb(23 23 23)', + ); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('announces PNG generation and prevents duplicate export actions', async () => { + const user = userEvent.setup(); + let finishExport: (() => void) | undefined; + mockDownloadMermaidPng.mockImplementationOnce( + () => + new Promise((resolve) => { + finishExport = resolve; + }), + ); + render('} filename="flow.mmd" />); + + const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' }); + await user.click(trigger); + await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_png' })); + + expect(trigger).toHaveAttribute('aria-busy', 'true'); + expect(trigger.querySelector('.lucide-loader-circle')).not.toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('com_ui_mermaid_exporting_png'); + + await user.click(trigger); + expect( + within(screen.getByRole('menu')).getByText('com_ui_mermaid_exporting_png'), + ).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'com_ui_export_png' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + + await act(async () => finishExport?.()); + expect(screen.getByRole('status')).toHaveTextContent('com_ui_mermaid_export_complete'); + }); +}); diff --git a/client/src/components/Messages/Content/Mermaid/Export.tsx b/client/src/components/Messages/Content/Mermaid/Export.tsx new file mode 100644 index 0000000000..e92d61af4a --- /dev/null +++ b/client/src/components/Messages/Content/Mermaid/Export.tsx @@ -0,0 +1,170 @@ +import React, { memo, useCallback, useId, useMemo, useRef, useState } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { FileCode2, FileImage, ImageDown, LoaderCircle } from 'lucide-react'; +import { DropdownPopup, TooltipAnchor, useToastContext } from '@librechat/client'; +import type { MermaidDimensions } from '~/utils/diagram/export'; +import type { MenuItemProps } from '~/common'; +import { downloadMermaidPng, downloadMermaidSvg } from '~/utils/diagram/export'; +import { useLocalize } from '~/hooks'; +import cn from '~/utils/cn'; + +interface MermaidExportProps { + filename: string; + svg?: string | null; + dimensions?: MermaidDimensions | null; + buttonClassName?: string; + /** Fullscreen re-roots the panel, so a menu portalled to the body would be + * rendered outside the visible fullscreen element. */ + portalElement?: HTMLElement | null; +} + +function surfaceBackground(): string | undefined { + const value = getComputedStyle(document.documentElement) + .getPropertyValue('--surface-primary-alt') + .trim(); + if (!value) { + return undefined; + } + if (/^[\d.]+(?:\s+[\d.]+){2}(?:\s*\/\s*[\d.]+%?)?$/.test(value)) { + return `rgb(${value})`; + } + return value.startsWith('var(') ? undefined : value; +} + +const MermaidExport = memo(function MermaidExport({ + filename, + svg, + dimensions, + buttonClassName, + portalElement, +}: MermaidExportProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, ''); + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [isExportingPng, setIsExportingPng] = useState(false); + const [exportStatus, setExportStatus] = useState(''); + const isBusy = isExportingPng; + let liveMessage = exportStatus; + if (isExportingPng) { + liveMessage = localize('com_ui_mermaid_exporting_png'); + } + + const showExportError = useCallback(() => { + setExportStatus(localize('com_ui_mermaid_export_failed')); + showToast({ status: 'error', message: localize('com_ui_mermaid_export_failed') }); + }, [localize, showToast]); + + const restoreTriggerFocus = useCallback(() => { + requestAnimationFrame(() => triggerRef.current?.focus()); + }, []); + + const handleSvgExport = useCallback(() => { + if (svg == null) { + return; + } + + try { + downloadMermaidSvg(svg, filename, surfaceBackground()); + setExportStatus(localize('com_ui_mermaid_export_complete')); + } catch { + showExportError(); + } finally { + restoreTriggerFocus(); + } + }, [filename, localize, restoreTriggerFocus, showExportError, svg]); + + const handlePngExport = useCallback(() => { + if (svg == null || isExportingPng) { + return; + } + + setIsExportingPng(true); + setExportStatus(''); + void downloadMermaidPng(svg, filename, dimensions, surfaceBackground()) + .then(() => setExportStatus(localize('com_ui_mermaid_export_complete'))) + .catch(showExportError) + .finally(() => setIsExportingPng(false)); + restoreTriggerFocus(); + }, [dimensions, filename, isExportingPng, localize, restoreTriggerFocus, showExportError, svg]); + + const dropdownItems = useMemo(() => { + const statusItems: MenuItemProps[] = []; + if (isBusy) { + statusItems.push({ + label: liveMessage, + disabled: true, + icon: , + className: 'text-text-secondary', + }); + } + + return [ + ...statusItems, + { + label: localize('com_ui_export_svg'), + icon: , + disabled: svg == null || isExportingPng, + onClick: handleSvgExport, + }, + { + label: localize('com_ui_export_png'), + icon: , + disabled: svg == null || isExportingPng, + onClick: handlePngExport, + }, + ]; + }, [handlePngExport, handleSvgExport, isBusy, isExportingPng, liveMessage, localize, svg]); + + return ( + <> + + {isBusy ? ( +