From 8da51562f5ff6acbd4b128cf2ec479e63bf5c448 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:02:42 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=9C=20feat:=20Open=20Mermaid=20Diagram?= =?UTF-8?q?s=20as=20Artifacts=20with=20SVG/PNG=20Export=20(#14713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open Mermaid diagrams in the artifact panel with SVG and PNG export Mermaid diagrams previously rendered inline only, and the artifact panel routed every artifact through Sandpack even when no bundler was needed. - Route Mermaid artifacts to a direct renderer in ArtifactTabs, moving the Sandpack path into a lazily loaded SandboxArtifactTabs so opening a diagram no longer pulls in the bundler chrome or the startup config. - Add an inline artifact card that opens the diagram in the panel instead of rendering the same diagram twice. - Add SVG and PNG export from both the inline diagram and the panel header, with size-capped canvas scaling and background compositing. - Lazy-load the artifact panel in Presentation and ShareArtifacts. - Accessibility: label the panel as a dialog on mobile with a focus trap, make the mobile resize handle keyboard operable, restore focus to the opener on close, and honor prefers-reduced-motion. - Fix the generated Sandpack wrapper to serialize diagram source instead of interpolating it into a template literal. - Cover the new paths with unit tests and a cross-browser Playwright spec. * fix: keep Mermaid artifact identity and render state per diagram Addresses three review findings on the Mermaid artifact panel. Mermaid fences do not consume a code-block index, so every diagram in a message received the same `mermaid-${blockIndex}` and therefore the same Recoil artifact key: expanding one overwrote the other, and both cards read as selected. Mermaid fences now carry their own index sequence, seeded per markdown block the same way the code and artifact counters are, so the id stays stable across streamed tokens. The panel renderer is keyed by artifact id, so switching directly between two diagrams can no longer carry the previous render, its dimensions, or its export payload across the boundary while the new source debounces. Editing an open diagram still does not remount. The preview Refresh action drives the Sandpack client, which a Mermaid preview never populates, so it only covered the panel with a spinner. It is hidden for Mermaid, which offers its own retry on render failure. Also drops com_ui_mermaid_export_preparing and com_ui_mermaid_source, which no longer have call sites, fixing the unused-i18n-keys check. * fix: bind Mermaid preview and export to the artifact on screen Three further review findings, all on state outliving what it describes. The editor reset in ArtifactTabs only lands after commit, so the render that switched artifacts still passed the previous artifact's editor text to the freshly keyed renderer, which mounted showing (and exporting) the diagram just navigated away from. Editor text is now ignored until the reset catches up. SandboxArtifactTabs carried the same pattern and gets the same guard. Switching to the code tab unmounts the preview, but the export payload survived it, so the toolbar kept exporting a diagram that was no longer on screen and no longer matched an edited source. The renderer now withdraws its payload on unmount, and the export action is scoped to the preview tab. The diagram canvas mounts only once there is a diagram to show, so the ResizeObserver ran against a null ref while the placeholder was up and never saw the real element. Wide diagrams were fitted to the default 700px and clipped in narrower panels. Observation now re-runs when the canvas appears. * fix: scope Mermaid artifact ids to the content part Each content part renders its own markdown tree, so the per-message Mermaid counter restarts at zero in every part. Diagrams sitting either side of a tool call therefore both resolved to `mermaid-artifact-${messageId}-mermaid-0`: one registration overwrote the other and both cards shared a selection state. The part index the message context already carries now takes part in the scope. * fix: keep the Mermaid export menu reachable in fullscreen The artifact panel gained a fullscreen mode on dev, which re-roots the panel into the fullscreen element and portals the copy and version popovers there so they stay visible. The Mermaid export menu portals to the body, so once these branches met it opened outside the fullscreen element and rendered invisible. It now takes the same portal target. * fix: heal Mermaid registrations and cap PNG canvases after rounding Two findings from the latest review pass. Closing the panel unmounts Artifacts, whose useArtifacts cleanup wipes artifactsState while the inline cards stay on screen. The Mermaid card never observed that, so reopening one card restored only itself and any other expanded diagram vanished from the version navigator until it was clicked again. It now subscribes to its own slice and re-registers when the entry goes missing, matching the self-heal ToolArtifactCard already documents. The write is a no-op when the entry matches, so it settles. Rounding each PNG side independently could carry the product back over the 16.7M pixel budget the scale was picked to satisfy: 3129x50000 resolved to 1025x16374, which is 16,783,350 pixels and enough for a browser enforcing the area limit to reject toBlob outright. Rounding down cannot exceed the budget, since the bounding scale is derived from it. --- client/src/Providers/CodeBlockContext.tsx | 20 +- client/src/common/artifacts.ts | 2 + .../Artifacts/ArtifactTabs.test.tsx | 320 ++++++++++ .../src/components/Artifacts/ArtifactTabs.tsx | 107 ++-- .../components/Artifacts/Artifacts.test.tsx | 256 ++++++++ client/src/components/Artifacts/Artifacts.tsx | 156 ++++- .../Artifacts/Mermaid/Export.test.tsx | 65 ++ .../components/Artifacts/Mermaid/Export.tsx | 36 ++ .../Artifacts/SandboxArtifactTabs.tsx | 79 +++ .../Chat/Messages/Content/MarkdownBlocks.tsx | 14 +- .../Messages/Content/MarkdownComponents.tsx | 10 +- .../Content/Parts/ToolMermaidArtifact.tsx | 24 +- .../Parts/__tests__/ArtifactRouting.test.tsx | 23 +- .../__tests__/MermaidBlockIds.test.tsx | 147 +++++ .../Chat/Messages/Content/splitMarkdown.ts | 32 +- .../src/components/Chat/Presentation.test.tsx | 108 ++++ client/src/components/Chat/Presentation.tsx | 9 +- .../Messages/Content/Mermaid/Export.test.tsx | 160 +++++ .../Messages/Content/Mermaid/Export.tsx | 170 ++++++ .../Messages/Content/Mermaid/Mermaid.test.tsx | 560 ++++++++++++++++++ .../Messages/Content/Mermaid/Mermaid.tsx | 452 ++++++++++---- .../Content/Mermaid/MermaidArtifactCard.tsx | 51 ++ .../Content/Mermaid/MermaidDialog.tsx | 50 +- .../Content/Mermaid/MermaidHeader.test.tsx | 47 ++ .../Content/Mermaid/MermaidHeader.tsx | 38 +- .../Content/Mermaid/useSvgProcessing.test.tsx | 103 ++++ .../Content/Mermaid/useSvgProcessing.ts | 99 +--- .../src/components/Share/ShareArtifacts.tsx | 9 +- client/src/hooks/Mermaid/useMermaid.ts | 11 +- client/src/locales/en/translation.json | 12 +- client/src/utils/__tests__/mermaid.test.ts | 15 + client/src/utils/artifacts.ts | 3 +- client/src/utils/diagram/export.test.ts | 177 ++++++ client/src/utils/diagram/export.ts | 245 ++++++++ client/src/utils/mermaid.ts | 7 +- e2e/playwright.config.mermaid.ts | 26 + e2e/setup/fake-model.js | 34 ++ e2e/specs/mock/mermaid-artifacts.spec.ts | 199 +++++++ 38 files changed, 3584 insertions(+), 292 deletions(-) create mode 100644 client/src/components/Artifacts/ArtifactTabs.test.tsx create mode 100644 client/src/components/Artifacts/Artifacts.test.tsx create mode 100644 client/src/components/Artifacts/Mermaid/Export.test.tsx create mode 100644 client/src/components/Artifacts/Mermaid/Export.tsx create mode 100644 client/src/components/Artifacts/SandboxArtifactTabs.tsx create mode 100644 client/src/components/Chat/Messages/Content/__tests__/MermaidBlockIds.test.tsx create mode 100644 client/src/components/Chat/Presentation.test.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/Export.test.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/Export.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/Mermaid.test.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/MermaidArtifactCard.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/MermaidHeader.test.tsx create mode 100644 client/src/components/Messages/Content/Mermaid/useSvgProcessing.test.tsx create mode 100644 client/src/utils/diagram/export.test.ts create mode 100644 client/src/utils/diagram/export.ts create mode 100644 e2e/playwright.config.mermaid.ts create mode 100644 e2e/specs/mock/mermaid-artifacts.spec.ts 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 ? ( +