From 771227ecf91dae46c8108836ecaf20737820e6f6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 6 Mar 2026 15:02:04 -0500 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=8F=8E=EF=B8=8F=20refactor:=20Replace?= =?UTF-8?q?=20Sandpack=20Code=20Editor=20with=20Monaco=20for=20Artifact=20?= =?UTF-8?q?Editing=20(#12109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Code Editor and Auto Scroll Functionality - Added a useEffect hook in CodeEditor to sync streaming content with Sandpack without remounting the provider, improving performance and user experience. - Updated useAutoScroll to accept an optional editorRef, allowing for dynamic scroll container selection based on the editor's state. - Refactored ArtifactTabs to utilize the new editorRef in the useAutoScroll hook, ensuring consistent scrolling behavior during content updates. - Introduced stableFiles and mergedFiles logic in CodeEditor to optimize file handling and prevent unnecessary updates during streaming content changes. * refactor: Update CodeEditor to Sync Streaming Content Based on Read-Only State - Modified the useEffect hook in CodeEditor to conditionally sync streaming content with Sandpack only when in read-only mode, preventing unnecessary updates during user edits. - Enhanced the dependency array of the useEffect hook to include the readOnly state, ensuring accurate synchronization behavior. * refactor: Monaco Editor for Artifact Code Editing * refactor: Clean up ArtifactCodeEditor and ArtifactTabs components - Removed unused scrollbar styles from mobile.css to streamline the code. - Refactored ArtifactCodeEditor to improve content synchronization and read-only state handling. - Enhanced ArtifactTabs by removing unnecessary context usage and optimizing component structure for better readability. * feat: Add support for new artifact type 'application/vnd.ant.react' - Introduced handling for 'application/vnd.ant.react' in artifactFilename, artifactTemplate, and dependenciesMap. - Updated relevant mappings to ensure proper integration of the new artifact type within the application. * refactor:ArtifactCodeEditor with Monaco Editor Configuration - Added support for disabling validation in the Monaco Editor to improve the artifact viewer/editor experience. - Introduced a new type definition for Monaco to enhance type safety. - Updated the handling of the 'application/vnd.ant.react' artifact type to ensure proper integration with the editor. * refactor: Clean up ArtifactCodeEditor and mobile.css - Removed unnecessary whitespace in mobile.css for cleaner code. - Refactored ArtifactCodeEditor to streamline language mapping and type handling, enhancing readability and maintainability. - Consolidated language and type mappings into dedicated constants for improved clarity and efficiency. * feat: Integrate Monaco Editor for Enhanced Code Editing Experience - Added the Monaco Editor as a dependency to improve the code editing capabilities within the ArtifactCodeEditor component. - Refactored the handling of TypeScript and JavaScript defaults in the Monaco Editor configuration for better type safety and clarity. - Streamlined the setup for disabling validation, enhancing the artifact viewer/editor experience. * fix: Update ArtifactCodeEditor to handle null content checks - Modified conditional checks in ArtifactCodeEditor to use `art.content != null` instead of `art.content` for improved null safety. - Ensured consistent handling of artifact content across various useEffect hooks to prevent potential errors when content is null. * fix: Refine content comparison logic in ArtifactCodeEditor - Updated the condition for checking if the code is not original by removing the redundant null check for `art.content`, ensuring more concise and clear logic. - This change enhances the readability of the code and maintains the integrity of content comparison within the editor. * fix: Simplify code comparison logic in ArtifactCodeEditor - Removed redundant null check for the `code` variable, ensuring a more straightforward comparison with the current update reference. - This change improves code clarity and maintains the integrity of the content comparison logic within the editor. --- client/package.json | 2 + .../Artifacts/ArtifactCodeEditor.tsx | 490 +++++++++++------- .../src/components/Artifacts/ArtifactTabs.tsx | 29 +- client/src/components/Artifacts/Artifacts.tsx | 4 +- client/src/components/Artifacts/Code.tsx | 75 +-- client/src/hooks/Artifacts/useAutoScroll.ts | 47 -- client/src/mobile.css | 20 - client/src/utils/artifacts.ts | 3 + package-lock.json | 64 +++ 9 files changed, 385 insertions(+), 349 deletions(-) delete mode 100644 client/src/hooks/Artifacts/useAutoScroll.ts diff --git a/client/package.json b/client/package.json index 0f7ffed04c..31e75c4702 100644 --- a/client/package.json +++ b/client/package.json @@ -38,6 +38,7 @@ "@librechat/client": "*", "@marsidev/react-turnstile": "^1.1.0", "@mcp-ui/client": "^5.7.0", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "1.0.2", "@radix-ui/react-checkbox": "^1.0.3", @@ -146,6 +147,7 @@ "jest": "^30.2.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", + "monaco-editor": "^0.55.0", "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", "postcss": "^8.4.31", diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 4ab2b182b8..ddf8dc84fa 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -1,206 +1,326 @@ -import React, { useMemo, useState, useEffect, useRef, memo } from 'react'; +import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import debounce from 'lodash/debounce'; -import { KeyBinding } from '@codemirror/view'; -import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; -import { - useSandpack, - SandpackCodeEditor, - SandpackProvider as StyledProvider, -} from '@codesandbox/sandpack-react'; -import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled'; -import type { SandpackBundlerFile } from '@codesandbox/sandpack-client'; -import type { CodeEditorRef } from '@codesandbox/sandpack-react'; -import type { ArtifactFiles, Artifact } from '~/common'; -import { useEditArtifact, useGetStartupConfig } from '~/data-provider'; +import MonacoEditor from '@monaco-editor/react'; +import type { Monaco } from '@monaco-editor/react'; +import type { editor } from 'monaco-editor'; +import type { Artifact } from '~/common'; import { useMutationState, useCodeState } from '~/Providers/EditorContext'; import { useArtifactsContext } from '~/Providers'; -import { sharedFiles, sharedOptions } from '~/utils/artifacts'; +import { useEditArtifact } from '~/data-provider'; -const CodeEditor = memo( - ({ - fileKey, - readOnly, - artifact, - editorRef, - }: { - fileKey: string; - readOnly?: boolean; - artifact: Artifact; - editorRef: React.MutableRefObject; - }) => { - const { sandpack } = useSandpack(); - const [currentUpdate, setCurrentUpdate] = useState(null); - const { isMutating, setIsMutating } = useMutationState(); - const { setCurrentCode } = useCodeState(); - const editArtifact = useEditArtifact({ - onMutate: (vars) => { - setIsMutating(true); - setCurrentUpdate(vars.updated); - }, - onSuccess: () => { - setIsMutating(false); - setCurrentUpdate(null); - }, - onError: () => { - setIsMutating(false); - }, - }); +const LANG_MAP: Record = { + javascript: 'javascript', + typescript: 'typescript', + python: 'python', + css: 'css', + json: 'json', + markdown: 'markdown', + html: 'html', + xml: 'xml', + sql: 'sql', + yaml: 'yaml', + shell: 'shell', + bash: 'shell', + tsx: 'typescript', + jsx: 'javascript', + c: 'c', + cpp: 'cpp', + java: 'java', + go: 'go', + rust: 'rust', + kotlin: 'kotlin', + swift: 'swift', + php: 'php', + ruby: 'ruby', + r: 'r', + lua: 'lua', + scala: 'scala', + perl: 'perl', +}; - /** - * Create stable debounced mutation that doesn't depend on changing callbacks - * Use refs to always access the latest values without recreating the debounce - */ - const artifactRef = useRef(artifact); - const isMutatingRef = useRef(isMutating); - const currentUpdateRef = useRef(currentUpdate); - const editArtifactRef = useRef(editArtifact); - const setCurrentCodeRef = useRef(setCurrentCode); +const TYPE_MAP: Record = { + 'text/html': 'html', + 'application/vnd.code-html': 'html', + 'application/vnd.react': 'typescript', + 'application/vnd.ant.react': 'typescript', + 'text/markdown': 'markdown', + 'text/md': 'markdown', + 'text/plain': 'plaintext', + 'application/vnd.mermaid': 'markdown', +}; - useEffect(() => { - artifactRef.current = artifact; - }, [artifact]); +function getMonacoLanguage(type?: string, language?: string): string { + if (language && LANG_MAP[language]) { + return LANG_MAP[language]; + } + return TYPE_MAP[type ?? ''] ?? 'plaintext'; +} - useEffect(() => { - isMutatingRef.current = isMutating; - }, [isMutating]); - - useEffect(() => { - currentUpdateRef.current = currentUpdate; - }, [currentUpdate]); - - useEffect(() => { - editArtifactRef.current = editArtifact; - }, [editArtifact]); - - useEffect(() => { - setCurrentCodeRef.current = setCurrentCode; - }, [setCurrentCode]); - - /** - * Create debounced mutation once - never recreate it - * All values are accessed via refs so they're always current - */ - const debouncedMutation = useMemo( - () => - debounce((code: string) => { - if (readOnly) { - return; - } - if (isMutatingRef.current) { - return; - } - if (artifactRef.current.index == null) { - return; - } - - const artifact = artifactRef.current; - const artifactIndex = artifact.index; - const isNotOriginal = - code && artifact.content != null && code.trim() !== artifact.content.trim(); - const isNotRepeated = - currentUpdateRef.current == null - ? true - : code != null && code.trim() !== currentUpdateRef.current.trim(); - - if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) { - setCurrentCodeRef.current(code); - editArtifactRef.current.mutate({ - index: artifactIndex, - messageId: artifact.messageId ?? '', - original: artifact.content, - updated: code, - }); - } - }, 500), - [readOnly], - ); - - /** - * Listen to Sandpack file changes and trigger debounced mutation - */ - useEffect(() => { - const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code; - if (currentCode) { - debouncedMutation(currentCode); - } - }, [sandpack.files, fileKey, debouncedMutation]); - - /** - * Cleanup: cancel pending mutations when component unmounts or artifact changes - */ - useEffect(() => { - return () => { - debouncedMutation.cancel(); - }; - }, [artifact.id, debouncedMutation]); - - return ( - (completionKeymap)} - className="hljs language-javascript bg-black" - /> - ); - }, -); - -export const ArtifactCodeEditor = function ({ - files, - fileKey, - template, +export const ArtifactCodeEditor = function ArtifactCodeEditor({ artifact, - editorRef, - sharedProps, + monacoRef, readOnly: externalReadOnly, }: { - fileKey: string; artifact: Artifact; - files: ArtifactFiles; - template: SandpackProviderProps['template']; - sharedProps: Partial; - editorRef: React.MutableRefObject; + monacoRef: React.MutableRefObject; readOnly?: boolean; }) { - const { data: config } = useGetStartupConfig(); const { isSubmitting } = useArtifactsContext(); - const options: typeof sharedOptions = useMemo(() => { - if (!config) { - return sharedOptions; - } - return { - ...sharedOptions, - activeFile: '/' + fileKey, - bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, - }; - }, [config, template, fileKey]); - const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false); - const [readOnly, setReadOnly] = useState(initialReadOnly); - useEffect(() => { - setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false)); - }, [isSubmitting, externalReadOnly]); + const readOnly = (externalReadOnly ?? false) || isSubmitting; + const { setCurrentCode } = useCodeState(); + const [currentUpdate, setCurrentUpdate] = useState(null); + const { isMutating, setIsMutating } = useMutationState(); + const editArtifact = useEditArtifact({ + onMutate: (vars) => { + setIsMutating(true); + setCurrentUpdate(vars.updated); + }, + onSuccess: () => { + setIsMutating(false); + setCurrentUpdate(null); + }, + onError: () => { + setIsMutating(false); + }, + }); - if (Object.keys(files).length === 0) { + const artifactRef = useRef(artifact); + const isMutatingRef = useRef(isMutating); + const currentUpdateRef = useRef(currentUpdate); + const editArtifactRef = useRef(editArtifact); + const setCurrentCodeRef = useRef(setCurrentCode); + const prevContentRef = useRef(artifact.content ?? ''); + const prevArtifactId = useRef(artifact.id); + const prevReadOnly = useRef(readOnly); + + artifactRef.current = artifact; + isMutatingRef.current = isMutating; + currentUpdateRef.current = currentUpdate; + editArtifactRef.current = editArtifact; + setCurrentCodeRef.current = setCurrentCode; + + const debouncedMutation = useMemo( + () => + debounce((code: string) => { + if (readOnly || isMutatingRef.current || artifactRef.current.index == null) { + return; + } + const art = artifactRef.current; + const isNotOriginal = art.content != null && code.trim() !== art.content.trim(); + const isNotRepeated = + currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim(); + + if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) { + setCurrentCodeRef.current(code); + editArtifactRef.current.mutate({ + index: art.index, + messageId: art.messageId ?? '', + original: art.content, + updated: code, + }); + } + }, 500), + [readOnly], + ); + + useEffect(() => { + return () => debouncedMutation.cancel(); + }, [artifact.id, debouncedMutation]); + + /** + * Streaming: use model.applyEdits() to append new content. + * Unlike setValue/pushEditOperations, applyEdits preserves existing + * tokens so syntax highlighting doesn't flash during updates. + */ + useEffect(() => { + const ed = monacoRef.current; + if (!ed || !readOnly) { + return; + } + const newContent = artifact.content ?? ''; + const prev = prevContentRef.current; + + if (newContent === prev) { + return; + } + + const model = ed.getModel(); + if (!model) { + return; + } + + if (newContent.startsWith(prev) && prev.length > 0) { + const appended = newContent.slice(prev.length); + const endPos = model.getPositionAt(model.getValueLength()); + model.applyEdits([ + { + range: { + startLineNumber: endPos.lineNumber, + startColumn: endPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }, + text: appended, + }, + ]); + } else { + model.setValue(newContent); + } + + prevContentRef.current = newContent; + ed.revealLine(model.getLineCount()); + }, [artifact.content, readOnly, monacoRef]); + + useEffect(() => { + if (artifact.id === prevArtifactId.current) { + return; + } + prevArtifactId.current = artifact.id; + prevContentRef.current = artifact.content ?? ''; + const ed = monacoRef.current; + if (ed && artifact.content != null) { + ed.getModel()?.setValue(artifact.content); + } + }, [artifact.id, artifact.content, monacoRef]); + + useEffect(() => { + if (prevReadOnly.current && !readOnly && artifact.content != null) { + const ed = monacoRef.current; + if (ed) { + ed.getModel()?.setValue(artifact.content); + prevContentRef.current = artifact.content; + } + } + prevReadOnly.current = readOnly; + }, [readOnly, artifact.content, monacoRef]); + + const handleChange = useCallback( + (value: string | undefined) => { + if (value === undefined || readOnly) { + return; + } + prevContentRef.current = value; + setCurrentCode(value); + if (value.length > 0) { + debouncedMutation(value); + } + }, + [readOnly, debouncedMutation, setCurrentCode], + ); + + /** + * Disable all validation — this is an artifact viewer/editor, not an IDE. + * Note: these are global Monaco settings that affect all editor instances on the page. + * The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript` + * as `{ deprecated: true }` while the runtime API is fully functional. + */ + const handleBeforeMount = useCallback((monaco: Monaco) => { + const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages + .typescript as unknown as { + typescriptDefaults: { + setDiagnosticsOptions: (o: { + noSemanticValidation: boolean; + noSyntaxValidation: boolean; + }) => void; + setCompilerOptions: (o: { + allowNonTsExtensions: boolean; + allowJs: boolean; + jsx: number; + }) => void; + }; + javascriptDefaults: { + setDiagnosticsOptions: (o: { + noSemanticValidation: boolean; + noSyntaxValidation: boolean; + }) => void; + setCompilerOptions: (o: { + allowNonTsExtensions: boolean; + allowJs: boolean; + jsx: number; + }) => void; + }; + JsxEmit: { React: number }; + }; + const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true }; + const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React }; + typescriptDefaults.setDiagnosticsOptions(diagnosticsOff); + javascriptDefaults.setDiagnosticsOptions(diagnosticsOff); + typescriptDefaults.setCompilerOptions(compilerBase); + javascriptDefaults.setCompilerOptions(compilerBase); + }, []); + + const handleMount = useCallback( + (ed: editor.IStandaloneCodeEditor) => { + monacoRef.current = ed; + prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? ''; + if (readOnly) { + const model = ed.getModel(); + if (model) { + ed.revealLine(model.getLineCount()); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [monacoRef], + ); + + const language = getMonacoLanguage(artifact.type, artifact.language); + + const editorOptions = useMemo( + () => ({ + readOnly, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 13, + tabSize: 2, + wordWrap: 'on', + automaticLayout: true, + padding: { top: 8 }, + renderLineHighlight: readOnly ? 'none' : 'line', + cursorStyle: readOnly ? 'underline-thin' : 'line', + scrollbar: { + vertical: 'visible', + horizontal: 'auto', + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + useShadows: false, + alwaysConsumeMouseWheel: false, + }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + folding: false, + glyphMargin: false, + colorDecorators: !readOnly, + occurrencesHighlight: readOnly ? 'off' : 'singleFile', + selectionHighlight: !readOnly, + renderValidationDecorations: readOnly ? 'off' : 'editable', + quickSuggestions: !readOnly, + suggestOnTriggerCharacters: !readOnly, + parameterHints: { enabled: !readOnly }, + hover: { enabled: !readOnly }, + matchBrackets: readOnly ? 'never' : 'always', + }), + [readOnly], + ); + + if (!artifact.content) { return null; } return ( - - - +
+ +
); }; diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 8e2a92eb9c..32332215f0 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -1,30 +1,26 @@ import { useRef, useEffect } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; -import type { CodeEditorRef } from '@codesandbox/sandpack-react'; +import type { editor } from 'monaco-editor'; import type { Artifact } from '~/common'; import { useCodeState } from '~/Providers/EditorContext'; -import { useArtifactsContext } from '~/Providers'; import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; -import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { useGetStartupConfig } from '~/data-provider'; import { ArtifactPreview } from './ArtifactPreview'; export default function ArtifactTabs({ artifact, - editorRef, previewRef, isSharedConvo, }: { artifact: Artifact; - editorRef: React.MutableRefObject; previewRef: React.MutableRefObject; isSharedConvo?: boolean; }) { - const { isSubmitting } = useArtifactsContext(); const { currentCode, setCurrentCode } = useCodeState(); const { data: startupConfig } = useGetStartupConfig(); + const monacoRef = useRef(null); const lastIdRef = useRef(null); useEffect(() => { @@ -34,33 +30,24 @@ export default function ArtifactTabs({ lastIdRef.current = artifact.id; }, [setCurrentCode, artifact.id]); - const content = artifact.content ?? ''; - const contentRef = useRef(null); - useAutoScroll({ ref: contentRef, content, isSubmitting }); - const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); return (
- + - + (); const previewRef = useRef(); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); @@ -297,7 +296,6 @@ export default function Artifacts() {
} previewRef={previewRef as React.MutableRefObject} isSharedConvo={isSharedConvo} /> diff --git a/client/src/components/Artifacts/Code.tsx b/client/src/components/Artifacts/Code.tsx index 6894ce775b..001b010908 100644 --- a/client/src/components/Artifacts/Code.tsx +++ b/client/src/components/Artifacts/Code.tsx @@ -1,11 +1,8 @@ -import React, { memo, useEffect, useRef, useState } from 'react'; +import React, { memo, useState } from 'react'; import copy from 'copy-to-clipboard'; -import rehypeKatex from 'rehype-katex'; -import ReactMarkdown from 'react-markdown'; import { Button } from '@librechat/client'; -import rehypeHighlight from 'rehype-highlight'; import { Copy, CircleCheckBig } from 'lucide-react'; -import { handleDoubleClick, langSubset } from '~/utils'; +import { handleDoubleClick } from '~/utils'; import { useLocalize } from '~/hooks'; type TCodeProps = { @@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC return {children}; }); -export const CodeMarkdown = memo( - ({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => { - const scrollRef = useRef(null); - const [userScrolled, setUserScrolled] = useState(false); - const currentContent = content; - const rehypePlugins = [ - [rehypeKatex], - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - subset: langSubset, - }, - ], - ]; - - useEffect(() => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) { - return; - } - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; - - if (!isNearBottom) { - setUserScrolled(true); - } else { - setUserScrolled(false); - } - }; - - scrollContainer.addEventListener('scroll', handleScroll); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - }, []); - - useEffect(() => { - const scrollContainer = scrollRef.current; - if (!scrollContainer || !isSubmitting || userScrolled) { - return; - } - - scrollContainer.scrollTop = scrollContainer.scrollHeight; - }, [content, isSubmitting, userScrolled]); - - return ( -
- - {currentContent} - -
- ); - }, -); - export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => { const localize = useLocalize(); const [isCopied, setIsCopied] = useState(false); diff --git a/client/src/hooks/Artifacts/useAutoScroll.ts b/client/src/hooks/Artifacts/useAutoScroll.ts deleted file mode 100644 index 1ddb9feb98..0000000000 --- a/client/src/hooks/Artifacts/useAutoScroll.ts +++ /dev/null @@ -1,47 +0,0 @@ -// hooks/useAutoScroll.ts -import { useEffect, useState } from 'react'; - -interface UseAutoScrollProps { - ref: React.RefObject; - content: string; - isSubmitting: boolean; -} - -export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => { - const [userScrolled, setUserScrolled] = useState(false); - - useEffect(() => { - const scrollContainer = ref.current; - if (!scrollContainer) { - return; - } - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; - - if (!isNearBottom) { - setUserScrolled(true); - } else { - setUserScrolled(false); - } - }; - - scrollContainer.addEventListener('scroll', handleScroll); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - }, [ref]); - - useEffect(() => { - const scrollContainer = ref.current; - if (!scrollContainer || !isSubmitting || userScrolled) { - return; - } - - scrollContainer.scrollTop = scrollContainer.scrollHeight; - }, [content, isSubmitting, userScrolled, ref]); - - return { userScrolled }; -}; diff --git a/client/src/mobile.css b/client/src/mobile.css index 20eeb5d1da..0d31b41134 100644 --- a/client/src/mobile.css +++ b/client/src/mobile.css @@ -349,26 +349,6 @@ animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; } -div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] { - scrollbar-gutter: stable !important; - background-color: rgba(205, 205, 205, 0.66) !important; -} - -div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar { - width: 12px !important; -} - -div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-thumb { - background-color: rgba(56, 56, 56) !important; - border-radius: 6px !important; - border: 2px solid transparent !important; - background-clip: padding-box !important; -} - -div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-track { - background-color: transparent !important; -} - .cm-content:focus { outline: none !important; } diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index a1caf8c07e..13f3a23b47 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -7,6 +7,7 @@ import type { const artifactFilename = { 'application/vnd.react': 'App.tsx', + 'application/vnd.ant.react': 'App.tsx', 'text/html': 'index.html', 'application/vnd.code-html': 'index.html', // mermaid and markdown types are handled separately in useArtifactProps.ts @@ -28,6 +29,7 @@ const artifactTemplate: Record< > = { 'text/html': 'static', 'application/vnd.react': 'react-ts', + 'application/vnd.ant.react': 'react-ts', 'application/vnd.mermaid': 'react-ts', 'application/vnd.code-html': 'static', 'text/markdown': 'react-ts', @@ -119,6 +121,7 @@ const dependenciesMap: Record< > = { 'application/vnd.mermaid': mermaidDependencies, 'application/vnd.react': standardDependencies, + 'application/vnd.ant.react': standardDependencies, 'text/html': standardDependencies, 'application/vnd.code-html': standardDependencies, 'text/markdown': markdownDependencies, diff --git a/package-lock.json b/package-lock.json index 6be9adfa61..e634f9f053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -389,6 +389,7 @@ "@librechat/client": "*", "@marsidev/react-turnstile": "^1.1.0", "@mcp-ui/client": "^5.7.0", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "1.0.2", "@radix-ui/react-checkbox": "^1.0.3", @@ -12437,6 +12438,29 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", @@ -34283,6 +34307,40 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mongodb": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", @@ -40426,6 +40484,12 @@ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/static-browser-server": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz", From 3a73907daac2ba7429697674d85f9f3a3f7d59a2 Mon Sep 17 00:00:00 2001 From: Carolina <143201623+CavMCarolina@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:42:23 -0300 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=90=20fix:=20Replace=20JS=20Image?= =?UTF-8?q?=20Scaling=20with=20CSS=20Viewport=20Constraints=20(#12089)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove scaleImage function that stretched vertical images * chore: lint * refactor: Simplify Image Component Usage Across Chat Parts - Removed height and width props from the Image component in various parts (Files, Part, ImageAttachment, LogContent) to streamline image rendering. - Introduced a constant for maximum image height in the Image component for consistent styling. - Updated related components to utilize the new simplified Image component structure, enhancing maintainability and reducing redundancy. * refactor: Simplify LogContent and Enhance Image Component Tests - Removed height and width properties from the ImageAttachment type in LogContent for cleaner code. - Updated the image rendering logic to rely solely on the filepath, improving clarity. - Enhanced the Image component tests with additional assertions for rendering behavior and accessibility. - Introduced new tests for OpenAIImageGen to validate image preloading and progress handling, ensuring robust functionality. --------- Co-authored-by: Danny Avila --- .../Chat/Messages/Content/Files.tsx | 8 - .../Chat/Messages/Content/Image.tsx | 38 +--- .../components/Chat/Messages/Content/Part.tsx | 13 +- .../Messages/Content/Parts/Attachment.tsx | 4 +- .../Messages/Content/Parts/LogContent.tsx | 33 +-- .../Parts/OpenAIImageGen/OpenAIImageGen.tsx | 99 ++------- .../Messages/Content/__tests__/Image.test.tsx | 200 ++++++++++++++++++ .../Content/__tests__/OpenAIImageGen.test.tsx | 182 ++++++++++++++++ client/src/utils/index.ts | 1 - client/src/utils/scaleImage.ts | 21 -- 10 files changed, 416 insertions(+), 183 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx create mode 100644 client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx delete mode 100644 client/src/utils/scaleImage.ts diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 8997d5e822..c55148891a 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -21,15 +21,7 @@ const Files = ({ message }: { message?: TMessage }) => { ))} diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index cd72733298..502a1c8e02 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -2,26 +2,20 @@ import React, { useState, useRef, useMemo } from 'react'; import { Skeleton } from '@librechat/client'; import { LazyLoadImage } from 'react-lazy-load-image-component'; import { apiBaseUrl } from 'librechat-data-provider'; -import { cn, scaleImage } from '~/utils'; +import { cn } from '~/utils'; import DialogImage from './DialogImage'; +/** Max display height for chat images (Tailwind JIT class) */ +const IMAGE_MAX_H = 'max-h-[45vh]' as const; + const Image = ({ imagePath, altText, - height, - width, - placeholderDimensions, className, args, }: { imagePath: string; altText: string; - height: number; - width: number; - placeholderDimensions?: { - height?: string; - width?: string; - }; className?: string; args?: { prompt?: string; @@ -33,7 +27,6 @@ const Image = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [isLoaded, setIsLoaded] = useState(false); - const containerRef = useRef(null); const triggerRef = useRef(null); const handleImageLoad = () => setIsLoaded(true); @@ -56,16 +49,6 @@ const Image = ({ return `${baseURL}${imagePath}`; }, [imagePath]); - const { width: scaledWidth, height: scaledHeight } = useMemo( - () => - scaleImage({ - originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width), - originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height), - containerRef, - }), - [placeholderDimensions, height, width], - ); - const downloadImage = async () => { try { const response = await fetch(absoluteImageUrl); @@ -96,7 +79,7 @@ const Image = ({ }; return ( -
+
diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index da2a8f175e..ce6750b048 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -12,11 +12,7 @@ interface LogContentProps { attachments?: TAttachment[]; } -type ImageAttachment = TFile & - TAttachmentMetadata & { - height: number; - width: number; - }; +type ImageAttachment = TFile & TAttachmentMetadata; const LogContent: React.FC = ({ output = '', renderImages, attachments }) => { const localize = useLocalize(); @@ -35,12 +31,8 @@ const LogContent: React.FC = ({ output = '', renderImages, atta const nonImageAtts: TAttachment[] = []; attachments?.forEach((attachment) => { - const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; - const isImage = - imageExtRegex.test(attachment.filename ?? '') && - width != null && - height != null && - filepath != null; + const { filepath = null } = attachment as TFile & TAttachmentMetadata; + const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null; if (isImage) { imageAtts.push(attachment as ImageAttachment); } else { @@ -100,18 +92,13 @@ const LogContent: React.FC = ({ output = '', renderImages, atta ))}
)} - {imageAttachments?.map((attachment, index) => { - const { width, height, filepath } = attachment; - return ( - - ); - })} + {imageAttachments?.map((attachment) => ( + + ))} ); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx index aa1b07827b..e0205e4957 100644 --- a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { PixelCard } from '@librechat/client'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import Image from '~/components/Chat/Messages/Content/Image'; import ProgressText from './ProgressText'; -import { scaleImage } from '~/utils'; +import { cn } from '~/utils'; + +const IMAGE_MAX_H = 'max-h-[45vh]' as const; +const IMAGE_FULL_H = 'h-[45vh]' as const; export default function OpenAIImageGen({ initialProgress = 0.1, @@ -28,8 +31,6 @@ export default function OpenAIImageGen({ const cancelled = (!isSubmitting && initialProgress < 1) || error === true; - let width: number | undefined; - let height: number | undefined; let quality: 'low' | 'medium' | 'high' = 'high'; // Parse args if it's a string @@ -41,61 +42,15 @@ export default function OpenAIImageGen({ parsedArgs = {}; } - try { - const argsObj = parsedArgs; - - if (argsObj && typeof argsObj.size === 'string') { - const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10)); - if (!isNaN(w) && !isNaN(h)) { - width = w; - height = h; - } - } else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) { - width = undefined; - height = undefined; + if (parsedArgs && typeof parsedArgs.quality === 'string') { + const q = parsedArgs.quality.toLowerCase(); + if (q === 'low' || q === 'medium' || q === 'high') { + quality = q; } - - if (argsObj && typeof argsObj.quality === 'string') { - const q = argsObj.quality.toLowerCase(); - if (q === 'low' || q === 'medium' || q === 'high') { - quality = q; - } - } - } catch (e) { - width = undefined; - height = undefined; } - // Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata const attachment = attachments?.[0]; - const { - width: imageWidth, - height: imageHeight, - filepath = null, - filename = '', - } = (attachment as TFile & TAttachmentMetadata) || {}; - - let origWidth = width ?? imageWidth; - let origHeight = height ?? imageHeight; - - if (origWidth === undefined || origHeight === undefined) { - origWidth = 1024; - origHeight = 1024; - } - - const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' }); - const containerRef = useRef(null); - - const updateDimensions = useCallback(() => { - if (origWidth && origHeight && containerRef.current) { - const scaled = scaleImage({ - originalWidth: origWidth, - originalHeight: origHeight, - containerRef, - }); - setDimensions(scaled); - } - }, [origWidth, origHeight]); + const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {}; useEffect(() => { if (isSubmitting) { @@ -156,45 +111,19 @@ export default function OpenAIImageGen({ } }, [initialProgress, cancelled]); - useEffect(() => { - updateDimensions(); - - const resizeObserver = new ResizeObserver(() => { - updateDimensions(); - }); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [updateDimensions]); - return ( <>
-
-
- {dimensions.width !== 'auto' && progress < 1 && ( - - )} +
+
+ {progress < 1 && }
diff --git a/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx new file mode 100644 index 0000000000..3654d8e075 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Image from '../Image'; + +jest.mock('~/utils', () => ({ + cn: (...classes: unknown[]) => + classes + .flat(Infinity) + .filter((c) => typeof c === 'string' && c.length > 0) + .join(' '), +})); + +jest.mock('librechat-data-provider', () => ({ + apiBaseUrl: () => '', +})); + +jest.mock('react-lazy-load-image-component', () => ({ + LazyLoadImage: ({ + alt, + src, + className, + onLoad, + placeholder, + visibleByDefault: _visibleByDefault, + ...rest + }: { + alt: string; + src: string; + className: string; + onLoad: () => void; + placeholder: React.ReactNode; + visibleByDefault?: boolean; + [key: string]: unknown; + }) => ( +
+ {alt} +
{placeholder}
+
+ ), +})); + +jest.mock('@librechat/client', () => ({ + Skeleton: ({ className, ...props }: React.HTMLAttributes) => ( +
+ ), +})); + +jest.mock('../DialogImage', () => ({ + __esModule: true, + default: ({ isOpen, src }: { isOpen: boolean; src: string }) => + isOpen ?
: null, +})); + +describe('Image', () => { + const defaultProps = { + imagePath: '/images/test.png', + altText: 'Test image', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders with max-h-[45vh] height constraint', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('max-h-[45vh]'); + }); + + it('renders with max-w-full to prevent landscape clipping', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('max-w-full'); + }); + + it('renders with w-auto and h-auto for natural aspect ratio', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('w-auto'); + expect(img.className).toContain('h-auto'); + }); + + it('starts with opacity-0 before image loads', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('opacity-0'); + expect(img.className).not.toContain('opacity-100'); + }); + + it('transitions to opacity-100 after image loads', () => { + render(); + const img = screen.getByTestId('lazy-image'); + + fireEvent.load(img); + + expect(img.className).toContain('opacity-100'); + }); + + it('applies custom className to the button wrapper', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('mb-4'); + }); + + it('sets correct alt text', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('alt', 'Test image'); + }); + }); + + describe('skeleton placeholder', () => { + it('renders skeleton with non-zero dimensions', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton.className).toContain('h-48'); + expect(skeleton.className).toContain('w-full'); + expect(skeleton.className).toContain('max-w-lg'); + }); + + it('renders skeleton with max-h constraint', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton.className).toContain('max-h-[45vh]'); + }); + + it('has accessible loading attributes', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveAttribute('aria-label', 'Loading image'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + }); + + describe('dialog interaction', () => { + it('opens dialog on button click after image loads', () => { + render(); + + const img = screen.getByTestId('lazy-image'); + fireEvent.load(img); + + expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByTestId('dialog-image')).toBeInTheDocument(); + }); + + it('does not render dialog before image loads', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); + }); + + it('has correct accessibility attributes on button', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'View Test image in dialog'); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + }); + }); + + describe('image URL resolution', () => { + it('passes /images/ paths through with base URL', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', '/images/test.png'); + }); + + it('passes absolute http URLs through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + }); + + it('passes data URIs through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc'); + }); + + it('passes non-/images/ paths through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', '/other/path.png'); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx new file mode 100644 index 0000000000..886b1b6294 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen'; + +jest.mock('~/utils', () => ({ + cn: (...classes: unknown[]) => + classes + .flat(Infinity) + .filter((c) => typeof c === 'string' && c.length > 0) + .join(' '), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/components/Chat/Messages/Content/Image', () => ({ + __esModule: true, + default: ({ + altText, + imagePath, + className, + }: { + altText: string; + imagePath: string; + className?: string; + }) => ( +
+ ), +})); + +jest.mock('@librechat/client', () => ({ + PixelCard: ({ progress }: { progress: number }) => ( +
+ ), +})); + +jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({ + __esModule: true, + default: ({ progress, error }: { progress: number; error: boolean }) => ( +
+ ), +})); + +describe('OpenAIImageGen', () => { + const defaultProps = { + initialProgress: 0.1, + isSubmitting: true, + toolName: 'image_gen_oai', + args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}', + output: null as string | null, + attachments: undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('image preloading', () => { + it('keeps Image mounted during generation (progress < 1)', () => { + render(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + + it('hides Image with invisible absolute while progress < 1', () => { + render(); + const image = screen.getByTestId('image-component'); + expect(image.className).toContain('invisible'); + expect(image.className).toContain('absolute'); + }); + + it('shows Image without hiding classes when progress >= 1', () => { + render( + , + ); + const image = screen.getByTestId('image-component'); + expect(image.className).not.toContain('invisible'); + expect(image.className).not.toContain('absolute'); + }); + }); + + describe('PixelCard visibility', () => { + it('shows PixelCard when progress < 1', () => { + render(); + expect(screen.getByTestId('pixel-card')).toBeInTheDocument(); + }); + + it('hides PixelCard when progress >= 1', () => { + render(); + expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument(); + }); + }); + + describe('layout classes', () => { + it('applies max-h-[45vh] to the outer container', () => { + const { container } = render(); + const outerDiv = container.querySelector('[class*="max-h-"]'); + expect(outerDiv?.className).toContain('max-h-[45vh]'); + }); + + it('applies h-[45vh] w-full to inner container during loading', () => { + const { container } = render(); + const innerDiv = container.querySelector('[class*="h-[45vh]"]'); + expect(innerDiv).not.toBeNull(); + expect(innerDiv?.className).toContain('w-full'); + }); + + it('applies w-auto to inner container when complete', () => { + const { container } = render( + , + ); + const overflowDiv = container.querySelector('[class*="overflow-hidden"]'); + expect(overflowDiv?.className).toContain('w-auto'); + }); + }); + + describe('args parsing', () => { + it('parses quality from args', () => { + render(); + expect(screen.getByTestId('progress-text')).toBeInTheDocument(); + }); + + it('handles invalid JSON args gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + render(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('handles object args', () => { + render( + , + ); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + }); + + describe('cancellation', () => { + it('shows error state when output contains error', () => { + render( + , + ); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + + it('shows cancelled state when not submitting and incomplete', () => { + render(); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 8946951ed8..2a7dfc4a88 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -29,7 +29,6 @@ export * from './share'; export * from './timestamps'; export { default as cn } from './cn'; export { default as logger } from './logger'; -export { default as scaleImage } from './scaleImage'; export { default as getLoginError } from './getLoginError'; export { default as cleanupPreset } from './cleanupPreset'; export { default as buildDefaultConvo } from './buildDefaultConvo'; diff --git a/client/src/utils/scaleImage.ts b/client/src/utils/scaleImage.ts deleted file mode 100644 index 11e051fbd9..0000000000 --- a/client/src/utils/scaleImage.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default function scaleImage({ - originalWidth, - originalHeight, - containerRef, -}: { - originalWidth?: number; - originalHeight?: number; - containerRef: React.RefObject; -}) { - const containerWidth = containerRef.current?.offsetWidth ?? 0; - - if (containerWidth === 0 || originalWidth == null || originalHeight == null) { - return { width: 'auto', height: 'auto' }; - } - - const aspectRatio = originalWidth / originalHeight; - const scaledWidth = Math.min(containerWidth, originalWidth); - const scaledHeight = scaledWidth / aspectRatio; - - return { width: `${scaledWidth}px`, height: `${scaledHeight}px` }; -} From cc3d62c64038b108381c6be6d0796299323babce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Airam=20Hern=C3=A1ndez=20Hern=C3=A1ndez?= <100208966+Airamhh@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:55:05 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Add=20Permis?= =?UTF-8?q?sion=20Guard=20for=20Temporary=20Chat=20Visibility=20(#12107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useHasAccess hook for TEMPORARY_CHAT permission type - Conditionally render TemporaryChat component based on user permissions - Ensures feature respects role-based access control Co-authored-by: Airam Hernández Hernández --- client/src/components/Chat/Header.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 611e96fdcc..9e44e804c9 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -35,6 +35,11 @@ function Header() { permission: Permissions.USE, }); + const hasAccessToTemporaryChat = useHasAccess({ + permissionType: PermissionTypes.TEMPORARY_CHAT, + permission: Permissions.USE, + }); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); return ( @@ -73,7 +78,7 @@ function Header() { - + {hasAccessToTemporaryChat === true && } )}
@@ -85,7 +90,7 @@ function Header() { - + {hasAccessToTemporaryChat === true && }
)}
From 6d0938be647d2053154153d19973ffedf13a6322 Mon Sep 17 00:00:00 2001 From: Lionel Ringenbach Date: Fri, 6 Mar 2026 16:05:56 -0800 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=94=92=20refactor:=20Set=20`ALLOW=5FS?= =?UTF-8?q?HARED=5FLINKS=5FPUBLIC`=20to=20`false`=20by=20Default=20(#12100?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: default ALLOW_SHARED_LINKS_PUBLIC to false for security Shared links were publicly accessible by default when ALLOW_SHARED_LINKS_PUBLIC was not explicitly set, which could lead to unintentional data exposure. Users may assume their authentication settings protect shared links when they do not. This changes the default behavior so shared links require JWT authentication unless ALLOW_SHARED_LINKS_PUBLIC is explicitly set to true. * Document ALLOW_SHARED_LINKS_PUBLIC in .env.example Add comment explaining ALLOW_SHARED_LINKS_PUBLIC setting. --------- Co-authored-by: Claude Co-authored-by: Danny Avila --- .env.example | 3 ++- api/server/routes/config.js | 4 +--- api/server/routes/share.js | 4 +--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 06d509a3ae..b851749baf 100644 --- a/.env.example +++ b/.env.example @@ -677,7 +677,8 @@ AZURE_CONTAINER_NAME=files #========================# ALLOW_SHARED_LINKS=true -ALLOW_SHARED_LINKS_PUBLIC=true +# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set. +ALLOW_SHARED_LINKS_PUBLIC=false #==============================# # Static File Cache Control # diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d2..0adc9272bb 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -16,9 +16,7 @@ const sharedLinksEnabled = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); const publicSharedLinksEnabled = - sharedLinksEnabled && - (process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined || - isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC)); + sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 6400b8b637..296644afde 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -19,9 +19,7 @@ const allowSharedLinks = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); if (allowSharedLinks) { - const allowSharedLinksPublic = - process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined || - isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); + const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); router.get( '/:shareId', allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth, From b93d60c416603f64e7e2c3cf16ef0e35aba96b23 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 6 Mar 2026 19:09:52 -0500 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=8E=9E=EF=B8=8F=20refactor:=20Image?= =?UTF-8?q?=20Rendering=20with=20Preview=20Caching=20and=20Layout=20Reserv?= =?UTF-8?q?ation=20(#12114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Update Image Component to Remove Lazy Loading and Enhance Rendering - Removed the react-lazy-load-image-component dependency from the Image component, simplifying the image loading process. - Updated the Image component to use a standard tag with async decoding for improved performance and user experience. - Adjusted related tests to reflect changes in image rendering behavior and ensure proper functionality without lazy loading. * refactor: Enhance Image Handling and Caching Across Components - Introduced a new previewCache utility for managing local blob preview URLs, improving image loading efficiency. - Updated the Image component and related parts (FileRow, Files, Part, ImageAttachment, LogContent) to utilize cached previews, enhancing rendering performance and user experience. - Added width and height properties to the Image component for better layout management and consistency across different usages. - Improved file handling logic in useFileHandling to cache previews during file uploads, ensuring quick access to image data. - Enhanced overall code clarity and maintainability by streamlining image rendering logic and reducing redundancy. * refactor: Enhance OpenAIImageGen Component with Image Dimensions - Added width and height properties to the OpenAIImageGen component for improved image rendering and layout management. - Updated the Image component usage within OpenAIImageGen to utilize the new dimensions, enhancing visual consistency and performance. - Improved code clarity by destructuring additional properties from the attachment object, streamlining the component's logic. * refactor: Implement Image Size Caching in DialogImage Component - Introduced an imageSizeCache to store and retrieve image sizes, enhancing performance by reducing redundant fetch requests. - Updated the getImageSize function to first check the cache before making network requests, improving efficiency in image handling. - Added decoding attribute to the image element for optimized rendering behavior. * refactor: Enhance UserAvatar Component with Avatar Caching and Error Handling - Introduced avatar caching logic to optimize avatar resolution based on user ID and avatar source, improving performance and reducing redundant image loads. - Implemented error handling for failed image loads, allowing for fallback to a default avatar when necessary. - Updated UserAvatar props to streamline the interface by removing the user object and directly accepting avatar-related properties. - Enhanced overall code clarity and maintainability by refactoring the component structure and logic. * fix: Layout Shift in Message and Placeholder Components for Consistent Height Management - Adjusted the height of the PlaceholderRow and related message components to ensure consistent rendering with a minimum height of 31px. - Updated the MessageParts and ContentRender components to utilize a minimum height for better layout stability. - Enhanced overall code clarity by standardizing the structure of message-related components. * tests: Update FileRow Component to Prefer Cached Previews for Image Rendering - Modified the image URL selection logic in the FileRow component to prioritize cached previews over file paths when uploads are complete, enhancing rendering performance and user experience. - Updated related tests to reflect changes in image URL handling, ensuring accurate assertions for both preview and file path scenarios. - Introduced a fallback mechanism to use file paths when no preview exists, improving robustness in file handling. * fix: Image cache lifecycle and dialog decoding - Add deletePreview/clearPreviewCache to previewCache.ts for blob URL cleanup - Wire deletePreview into useFileDeletion to revoke blobs on file delete - Move dimensionCache.set into useMemo to avoid side effects during render - Extract IMAGE_MAX_W_PX constant (512) to document coupling with max-w-lg - Export _resetImageCaches for test isolation - Change DialogImage decoding from "sync" to "async" to avoid blocking main thread * fix: Avatar cache invalidation and cleanup - Include avatarSrc in cache invalidation to prevent stale avatars - Remove unused username parameter from resolveAvatar - Skip caching when userId is empty to prevent cache key collisions * test: Fix test isolation and type safety - Reset module-level dimensionCache/paintedUrls in beforeEach via _resetImageCaches - Replace any[] with typed mock signature in cn mock for both test files * chore: Code quality improvements from review - Use barrel imports for previewCache in Files.tsx and Part.tsx - Single Map.get with truthy check instead of has+get in useEventHandlers - Add JSDoc comments explaining EmptyText margin removal and PlaceholderRow height - Fix FileRow toast showing "Deleting file" when file isn't actually deleted (progress < 1) * fix: Address remaining review findings (R1-R3) - Add deletePreview calls to deleteFiles batch path to prevent blob URL leaks - Change useFileDeletion import from deep path to barrel (~/utils) - Change useMemo to useEffect for dimensionCache.set (side effect, not derived value) * fix: Address audit comments 2, 5, and 7 - Fix files preservation to distinguish null (missing) from [] (empty) in finalHandler - Add auto-revoke on overwrite in cachePreview to prevent leaked blobs - Add removePreviewEntry for key transfer without revoke - Clean up stale temp_file_id cache entry after promotion to permanent file_id --- client/package.json | 3 +- .../components/Chat/Input/Files/FileRow.tsx | 14 +- .../Input/Files/__tests__/FileRow.spec.tsx | 36 +++- .../Chat/Messages/Content/DialogImage.tsx | 15 +- .../Chat/Messages/Content/Files.tsx | 20 +- .../Chat/Messages/Content/Image.tsx | 90 +++++---- .../components/Chat/Messages/Content/Part.tsx | 9 +- .../Messages/Content/Parts/Attachment.tsx | 4 +- .../Chat/Messages/Content/Parts/EmptyText.tsx | 3 +- .../Messages/Content/Parts/LogContent.tsx | 2 + .../Parts/OpenAIImageGen/OpenAIImageGen.tsx | 11 +- .../Messages/Content/__tests__/Image.test.tsx | 177 ++++++++---------- .../Content/__tests__/OpenAIImageGen.test.tsx | 4 +- .../components/Chat/Messages/MessageParts.tsx | 4 +- .../Chat/Messages/ui/MessageRender.tsx | 2 +- .../Chat/Messages/ui/PlaceholderRow.tsx | 3 +- client/src/components/Endpoints/Icon.tsx | 131 ++++++++----- .../src/components/Messages/ContentRender.tsx | 2 +- client/src/components/Share/Message.tsx | 2 +- .../Files/__tests__/useFileHandling.test.ts | 8 +- client/src/hooks/Files/useFileDeletion.ts | 11 ++ client/src/hooks/Files/useFileHandling.ts | 10 +- client/src/hooks/SSE/useEventHandlers.ts | 17 ++ client/src/utils/index.ts | 1 + client/src/utils/previewCache.ts | 35 ++++ package-lock.json | 23 +-- 26 files changed, 390 insertions(+), 247 deletions(-) create mode 100644 client/src/utils/previewCache.ts diff --git a/client/package.json b/client/package.json index 31e75c4702..c588ccc6d9 100644 --- a/client/package.json +++ b/client/package.json @@ -94,7 +94,6 @@ "react-gtm-module": "^2.0.11", "react-hook-form": "^7.43.9", "react-i18next": "^15.4.0", - "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.30.3", @@ -147,9 +146,9 @@ "jest": "^30.2.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", - "monaco-editor": "^0.55.0", "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", + "monaco-editor": "^0.55.0", "postcss": "^8.4.31", "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index ba8e73a8a2..bf04b16ade 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -3,10 +3,10 @@ import { useToastContext } from '@librechat/client'; import { EToolResources } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import { useDeleteFilesMutation } from '~/data-provider'; +import { logger, getCachedPreview } from '~/utils'; import { useFileDeletion } from '~/hooks/Files'; import FileContainer from './FileContainer'; import { useLocalize } from '~/hooks'; -import { logger } from '~/utils'; import Image from './Image'; export default function FileRow({ @@ -112,13 +112,15 @@ export default function FileRow({ ) .uniqueFiles.map((file: ExtendedFile, index: number) => { const handleDelete = () => { - showToast({ - message: localize('com_ui_deleting_file'), - status: 'info', - }); if (abortUpload && file.progress < 1) { abortUpload(); } + if (file.progress >= 1) { + showToast({ + message: localize('com_ui_deleting_file'), + status: 'info', + }); + } deleteFile({ file, setFiles }); }; const isImage = file.type?.startsWith('image') ?? false; @@ -134,7 +136,7 @@ export default function FileRow({ > {isImage ? ( ({ logger: { log: jest.fn(), }, + getCachedPreview: jest.fn(() => undefined), })); jest.mock('../Image', () => { @@ -95,7 +96,7 @@ describe('FileRow', () => { }; describe('Image URL Selection Logic', () => { - it('should use filepath instead of preview when progress is 1 (upload complete)', () => { + it('should prefer cached preview over filepath when upload is complete', () => { const file = createMockFile({ file_id: 'uploaded-file', preview: 'blob:http://localhost:3080/temp-preview', @@ -109,8 +110,7 @@ describe('FileRow', () => { renderFileRow(filesMap); const imageUrl = screen.getByTestId('image-url').textContent; - expect(imageUrl).toBe('/images/user123/uploaded-file__image.png'); - expect(imageUrl).not.toContain('blob:'); + expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview'); }); it('should use preview when progress is less than 1 (uploading)', () => { @@ -147,7 +147,7 @@ describe('FileRow', () => { expect(imageUrl).toBe('/images/user123/file-without-preview__image.png'); }); - it('should use filepath when both preview and filepath exist and progress is exactly 1', () => { + it('should prefer preview over filepath when both exist and progress is 1', () => { const file = createMockFile({ file_id: 'complete-file', preview: 'blob:http://localhost:3080/old-blob', @@ -161,7 +161,7 @@ describe('FileRow', () => { renderFileRow(filesMap); const imageUrl = screen.getByTestId('image-url').textContent; - expect(imageUrl).toBe('/images/user123/complete-file__image.png'); + expect(imageUrl).toBe('blob:http://localhost:3080/old-blob'); }); }); @@ -284,7 +284,7 @@ describe('FileRow', () => { const urls = screen.getAllByTestId('image-url').map((el) => el.textContent); expect(urls).toContain('blob:http://localhost:3080/preview-1'); - expect(urls).toContain('/images/user123/file-2__image.png'); + expect(urls).toContain('blob:http://localhost:3080/preview-2'); }); it('should deduplicate files with the same file_id', () => { @@ -321,10 +321,10 @@ describe('FileRow', () => { }); }); - describe('Regression: Blob URL Bug Fix', () => { - it('should NOT use revoked blob URL after upload completes', () => { + describe('Preview Cache Integration', () => { + it('should prefer preview blob URL over filepath for zero-flicker rendering', () => { const file = createMockFile({ - file_id: 'regression-test', + file_id: 'cache-test', preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b', filepath: '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', @@ -337,8 +337,24 @@ describe('FileRow', () => { renderFileRow(filesMap); const imageUrl = screen.getByTestId('image-url').textContent; + expect(imageUrl).toBe('blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b'); + }); - expect(imageUrl).not.toContain('blob:'); + it('should fall back to filepath when no preview exists', () => { + const file = createMockFile({ + file_id: 'no-preview', + preview: undefined, + filepath: + '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', + progress: 1, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; expect(imageUrl).toBe( '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', ); diff --git a/client/src/components/Chat/Messages/Content/DialogImage.tsx b/client/src/components/Chat/Messages/Content/DialogImage.tsx index cb496de646..b9cbe64555 100644 --- a/client/src/components/Chat/Messages/Content/DialogImage.tsx +++ b/client/src/components/Chat/Messages/Content/DialogImage.tsx @@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client'; import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react'; import { useLocalize } from '~/hooks'; +const imageSizeCache = new Map(); + const getQualityStyles = (quality: string): string => { if (quality === 'high') { return 'bg-green-100 text-green-800'; @@ -50,18 +52,26 @@ export default function DialogImage({ const closeButtonRef = useRef(null); const getImageSize = useCallback(async (url: string) => { + const cached = imageSizeCache.get(url); + if (cached) { + return cached; + } try { const response = await fetch(url, { method: 'HEAD' }); const contentLength = response.headers.get('Content-Length'); if (contentLength) { const bytes = parseInt(contentLength, 10); - return formatFileSize(bytes); + const result = formatFileSize(bytes); + imageSizeCache.set(url, result); + return result; } const fullResponse = await fetch(url); const blob = await fullResponse.blob(); - return formatFileSize(blob.size); + const result = formatFileSize(blob.size); + imageSizeCache.set(url, result); + return result; } catch (error) { console.error('Error getting image size:', error); return null; @@ -355,6 +365,7 @@ export default function DialogImage({ ref={imageRef} src={src} alt="Image" + decoding="async" className="block max-h-[85vh] object-contain" style={{ maxWidth: getImageMaxWidth(), diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index c55148891a..504e48e883 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -1,6 +1,7 @@ import { useMemo, memo } from 'react'; import type { TFile, TMessage } from 'librechat-data-provider'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; +import { getCachedPreview } from '~/utils'; import Image from './Image'; const Files = ({ message }: { message?: TMessage }) => { @@ -17,13 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => { {otherFiles.length > 0 && otherFiles.map((file) => )} {imageFiles.length > 0 && - imageFiles.map((file) => ( - - ))} + imageFiles.map((file) => { + const cached = file.file_id ? getCachedPreview(file.file_id) : undefined; + return ( + + ); + })} ); }; diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 502a1c8e02..7e3e12e65b 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -1,18 +1,36 @@ -import React, { useState, useRef, useMemo } from 'react'; +import React, { useState, useRef, useMemo, useEffect } from 'react'; import { Skeleton } from '@librechat/client'; -import { LazyLoadImage } from 'react-lazy-load-image-component'; import { apiBaseUrl } from 'librechat-data-provider'; -import { cn } from '~/utils'; import DialogImage from './DialogImage'; +import { cn } from '~/utils'; /** Max display height for chat images (Tailwind JIT class) */ -const IMAGE_MAX_H = 'max-h-[45vh]' as const; +export const IMAGE_MAX_H = 'max-h-[45vh]' as const; +/** Matches the `max-w-lg` Tailwind class on the wrapper button (32rem = 512px at 16px base) */ +const IMAGE_MAX_W_PX = 512; + +/** Caches image dimensions by src so remounts can reserve space */ +const dimensionCache = new Map(); +/** Tracks URLs that have been fully painted — skip skeleton on remount */ +const paintedUrls = new Set(); + +/** Test-only: resets module-level caches */ +export function _resetImageCaches(): void { + dimensionCache.clear(); + paintedUrls.clear(); +} + +function computeHeightStyle(w: number, h: number): React.CSSProperties { + return { height: `min(45vh, ${(h / w) * 100}vw, ${(h / w) * IMAGE_MAX_W_PX}px)` }; +} const Image = ({ imagePath, altText, className, args, + width, + height, }: { imagePath: string; altText: string; @@ -24,18 +42,15 @@ const Image = ({ style?: string; [key: string]: unknown; }; + width?: number; + height?: number; }) => { const [isOpen, setIsOpen] = useState(false); - const [isLoaded, setIsLoaded] = useState(false); const triggerRef = useRef(null); - const handleImageLoad = () => setIsLoaded(true); - - // Fix image path to include base path for subdirectory deployments const absoluteImageUrl = useMemo(() => { if (!imagePath) return imagePath; - // If it's already an absolute URL or doesn't start with /images/, return as is if ( imagePath.startsWith('http') || imagePath.startsWith('data:') || @@ -44,7 +59,6 @@ const Image = ({ return imagePath; } - // Get the base URL and prepend it to the image path const baseURL = apiBaseUrl(); return `${baseURL}${imagePath}`; }, [imagePath]); @@ -78,6 +92,17 @@ const Image = ({ } }; + useEffect(() => { + if (width && height && absoluteImageUrl) { + dimensionCache.set(absoluteImageUrl, { width, height }); + } + }, [absoluteImageUrl, width, height]); + + const dims = width && height ? { width, height } : dimensionCache.get(absoluteImageUrl); + const hasDimensions = !!(dims?.width && dims?.height); + const heightStyle = hasDimensions ? computeHeightStyle(dims.width, dims.height) : undefined; + const showSkeleton = hasDimensions && !paintedUrls.has(absoluteImageUrl); + return (
- {isLoaded && ( - - )} +
); }; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 55946d64d5..7bce7ac11d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider' import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts'; import { ErrorMessage } from './MessageContent'; import RetrievalCall from './RetrievalCall'; +import { getCachedPreview } from '~/utils'; import AgentHandoff from './AgentHandoff'; import CodeAnalyze from './CodeAnalyze'; import Container from './Container'; @@ -222,8 +223,14 @@ const Part = memo(function Part({ } } else if (part.type === ContentTypes.IMAGE_FILE) { const imageFile = part[ContentTypes.IMAGE_FILE]; + const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined; return ( - + ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index 132bac51ea..31e30772dc 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => { const [isLoaded, setIsLoaded] = useState(false); - const { filepath = null } = attachment as TFile & TAttachmentMetadata; + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; useEffect(() => { setIsLoaded(false); @@ -76,6 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
diff --git a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx index 1b514164df..409461a058 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx @@ -1,8 +1,9 @@ import { memo } from 'react'; +/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */ const EmptyTextPart = memo(() => { return ( -
+

diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index ce6750b048..a675ff06d8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -94,6 +94,8 @@ const LogContent: React.FC = ({ output = '', renderImages, atta )} {imageAttachments?.map((attachment) => ( { if (isSubmitting) { @@ -120,9 +125,11 @@ export default function OpenAIImageGen({

{progress < 1 && }
diff --git a/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx index 3654d8e075..e7e0b99f1e 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import Image from '../Image'; +import Image, { _resetImageCaches } from '../Image'; jest.mock('~/utils', () => ({ - cn: (...classes: unknown[]) => + cn: (...classes: (string | boolean | undefined | null)[]) => classes .flat(Infinity) - .filter((c) => typeof c === 'string' && c.length > 0) + .filter((c): c is string => typeof c === 'string' && c.length > 0) .join(' '), })); @@ -14,38 +14,6 @@ jest.mock('librechat-data-provider', () => ({ apiBaseUrl: () => '', })); -jest.mock('react-lazy-load-image-component', () => ({ - LazyLoadImage: ({ - alt, - src, - className, - onLoad, - placeholder, - visibleByDefault: _visibleByDefault, - ...rest - }: { - alt: string; - src: string; - className: string; - onLoad: () => void; - placeholder: React.ReactNode; - visibleByDefault?: boolean; - [key: string]: unknown; - }) => ( -
- {alt} -
{placeholder}
-
- ), -})); - jest.mock('@librechat/client', () => ({ Skeleton: ({ className, ...props }: React.HTMLAttributes) => (
@@ -65,45 +33,84 @@ describe('Image', () => { }; beforeEach(() => { + _resetImageCaches(); jest.clearAllMocks(); }); - describe('rendering', () => { + describe('rendering without dimensions', () => { it('renders with max-h-[45vh] height constraint', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img.className).toContain('max-h-[45vh]'); }); it('renders with max-w-full to prevent landscape clipping', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img.className).toContain('max-w-full'); }); it('renders with w-auto and h-auto for natural aspect ratio', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img.className).toContain('w-auto'); expect(img.className).toContain('h-auto'); }); - it('starts with opacity-0 before image loads', () => { + it('does not show skeleton without dimensions', () => { render(); - const img = screen.getByTestId('lazy-image'); - expect(img.className).toContain('opacity-0'); - expect(img.className).not.toContain('opacity-100'); + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); }); - it('transitions to opacity-100 after image loads', () => { + it('does not apply heightStyle without dimensions', () => { render(); - const img = screen.getByTestId('lazy-image'); + const button = screen.getByRole('button'); + expect(button.style.height).toBeFalsy(); + }); + }); + + describe('rendering with dimensions', () => { + it('shows skeleton behind image', () => { + render(); + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + }); + + it('applies computed heightStyle to button', () => { + render(); + const button = screen.getByRole('button'); + expect(button.style.height).toBeTruthy(); + expect(button.style.height).toContain('min(45vh'); + }); + + it('uses size-full object-contain on image when dimensions provided', () => { + render(); + const img = screen.getByRole('img'); + expect(img.className).toContain('size-full'); + expect(img.className).toContain('object-contain'); + }); + + it('skeleton is absolute inset-0', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton.className).toContain('absolute'); + expect(skeleton.className).toContain('inset-0'); + }); + + it('marks URL as painted on load and skips skeleton on rerender', () => { + const { rerender } = render(); + const img = screen.getByRole('img'); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); fireEvent.load(img); - expect(img.className).toContain('opacity-100'); + // Rerender same component — skeleton should not show (URL painted) + rerender(); + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); }); + }); + describe('common behavior', () => { it('applies custom className to the button wrapper', () => { render(); const button = screen.getByRole('button'); @@ -112,57 +119,9 @@ describe('Image', () => { it('sets correct alt text', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img).toHaveAttribute('alt', 'Test image'); }); - }); - - describe('skeleton placeholder', () => { - it('renders skeleton with non-zero dimensions', () => { - render(); - const skeleton = screen.getByTestId('skeleton'); - expect(skeleton.className).toContain('h-48'); - expect(skeleton.className).toContain('w-full'); - expect(skeleton.className).toContain('max-w-lg'); - }); - - it('renders skeleton with max-h constraint', () => { - render(); - const skeleton = screen.getByTestId('skeleton'); - expect(skeleton.className).toContain('max-h-[45vh]'); - }); - - it('has accessible loading attributes', () => { - render(); - const skeleton = screen.getByTestId('skeleton'); - expect(skeleton).toHaveAttribute('aria-label', 'Loading image'); - expect(skeleton).toHaveAttribute('aria-busy', 'true'); - }); - }); - - describe('dialog interaction', () => { - it('opens dialog on button click after image loads', () => { - render(); - - const img = screen.getByTestId('lazy-image'); - fireEvent.load(img); - - expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); - - const button = screen.getByRole('button'); - fireEvent.click(button); - - expect(screen.getByTestId('dialog-image')).toBeInTheDocument(); - }); - - it('does not render dialog before image loads', () => { - render(); - - const button = screen.getByRole('button'); - fireEvent.click(button); - - expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); - }); it('has correct accessibility attributes on button', () => { render(); @@ -172,28 +131,48 @@ describe('Image', () => { }); }); + describe('dialog interaction', () => { + it('opens dialog on button click', () => { + render(); + expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByTestId('dialog-image')).toBeInTheDocument(); + }); + + it('dialog is always mounted (not gated by load state)', () => { + render(); + // DialogImage mock returns null when isOpen=false, but the component is in the tree + // Clicking should immediately show it + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('dialog-image')).toBeInTheDocument(); + }); + }); + describe('image URL resolution', () => { it('passes /images/ paths through with base URL', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', '/images/test.png'); }); it('passes absolute http URLs through unchanged', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); }); it('passes data URIs through unchanged', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', 'data:image/png;base64,abc'); }); it('passes non-/images/ paths through unchanged', () => { render(); - const img = screen.getByTestId('lazy-image'); + const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', '/other/path.png'); }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx index 886b1b6294..ef8ac2807a 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx @@ -3,10 +3,10 @@ import { render, screen } from '@testing-library/react'; import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen'; jest.mock('~/utils', () => ({ - cn: (...classes: unknown[]) => + cn: (...classes: (string | boolean | undefined | null)[]) => classes .flat(Infinity) - .filter((c) => typeof c === 'string' && c.length > 0) + .filter((c): c is string => typeof c === 'string' && c.length > 0) .join(' '), })); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 88162f3287..7aa73a54e6 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) { )}
-
+
{isLast && isSubmitting ? ( -
+
) : ( -
+
; + return
; }); PlaceholderRow.displayName = 'PlaceholderRow'; diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index 3256145bfb..fae0f286d3 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,64 +1,102 @@ -import React, { memo, useState } from 'react'; +import React, { memo } from 'react'; import { UserIcon, useAvatar } from '@librechat/client'; -import type { TUser } from 'librechat-data-provider'; import type { IconProps } from '~/common'; import MessageEndpointIcon from './MessageEndpointIcon'; import { useAuthContext } from '~/hooks/AuthContext'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; +type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' }; + +/** + * Caches the resolved avatar decision per user ID. + * Invalidated when `user.avatar` changes (e.g., settings upload). + * Tracks failed image URLs so they fall back to SVG permanently for the session. + */ +const avatarCache = new Map< + string, + { avatar: string; avatarSrc: string; resolved: ResolvedAvatar } +>(); +const failedUrls = new Set(); + +function resolveAvatar(userId: string, userAvatar: string, avatarSrc: string): ResolvedAvatar { + if (!userId) { + const imgSrc = userAvatar || avatarSrc; + return imgSrc && !failedUrls.has(imgSrc) + ? { type: 'image', src: imgSrc } + : { type: 'fallback' }; + } + + const cached = avatarCache.get(userId); + if (cached && cached.avatar === userAvatar && cached.avatarSrc === avatarSrc) { + return cached.resolved; + } + + const imgSrc = userAvatar || avatarSrc; + const resolved: ResolvedAvatar = + imgSrc && !failedUrls.has(imgSrc) ? { type: 'image', src: imgSrc } : { type: 'fallback' }; + + avatarCache.set(userId, { avatar: userAvatar, avatarSrc, resolved }); + return resolved; +} + +function markAvatarFailed(userId: string, src: string): ResolvedAvatar { + failedUrls.add(src); + const fallback: ResolvedAvatar = { type: 'fallback' }; + const cached = avatarCache.get(userId); + if (cached) { + avatarCache.set(userId, { ...cached, resolved: fallback }); + } + return fallback; +} + type UserAvatarProps = { size: number; - user?: TUser; + avatar: string; avatarSrc: string; + userId: string; username: string; className?: string; }; -const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => { - const [imageError, setImageError] = useState(false); +const UserAvatar = memo( + ({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => { + const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc)); - const handleImageError = () => { - setImageError(true); - }; + React.useEffect(() => { + setResolved(resolveAvatar(userId, avatar, avatarSrc)); + }, [userId, avatar, avatarSrc]); - const renderDefaultAvatar = () => ( -
- -
- ); - - return ( -
- {(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) || - imageError ? ( - renderDefaultAvatar() - ) : ( - avatar - )} -
- ); -}); + return ( +
+ {resolved.type === 'image' ? ( + avatar setResolved(markAvatarFailed(userId, resolved.src))} + /> + ) : ( +
+ +
+ )} +
+ ); + }, +); UserAvatar.displayName = 'UserAvatar'; @@ -74,9 +112,10 @@ const Icon: React.FC = memo((props) => { return ( ); diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index a50b91c071..4114baefe4 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -144,7 +144,7 @@ const ContentRender = memo(function ContentRender({ )}
-
+
{messageLabel}
-
+
({ })); jest.mock('~/hooks/useLocalize', () => { - const fn = jest.fn((key: string) => key); + const fn = jest.fn((key: string) => key) as jest.Mock & { + TranslationKeys: Record; + }; fn.TranslationKeys = {}; return { __esModule: true, default: fn, TranslationKeys: {} }; }); @@ -87,6 +89,8 @@ jest.mock('../useUpdateFiles', () => ({ jest.mock('~/utils', () => ({ logger: { log: jest.fn() }, validateFiles: jest.fn(() => true), + cachePreview: jest.fn(), + getCachedPreview: jest.fn(() => undefined), })); const mockValidateFiles = jest.requireMock('~/utils').validateFiles; @@ -263,7 +267,7 @@ describe('useFileHandling', () => { it('falls back to "default" when no conversation endpoint and no override', async () => { mockConversation = { - conversationId: Constants.NEW_CONVO, + conversationId: Constants.NEW_CONVO as string, endpoint: null, endpointType: undefined, }; diff --git a/client/src/hooks/Files/useFileDeletion.ts b/client/src/hooks/Files/useFileDeletion.ts index 34d89e313b..c33ac2a50b 100644 --- a/client/src/hooks/Files/useFileDeletion.ts +++ b/client/src/hooks/Files/useFileDeletion.ts @@ -5,6 +5,7 @@ import type * as t from 'librechat-data-provider'; import type { UseMutateAsyncFunction } from '@tanstack/react-query'; import type { ExtendedFile, GenericSetter } from '~/common'; import useSetFilesToDelete from './useSetFilesToDelete'; +import { deletePreview } from '~/utils'; type FileMapSetter = GenericSetter>; @@ -88,6 +89,11 @@ const useFileDeletion = ({ }); } + deletePreview(file_id); + if (temp_file_id) { + deletePreview(temp_file_id); + } + if (attached) { return; } @@ -125,6 +131,11 @@ const useFileDeletion = ({ temp_file_id, embedded: embedded ?? false, }); + + deletePreview(file_id); + if (temp_file_id) { + deletePreview(temp_file_id); + } } if (setFiles) { diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 68d56e75ad..be62700651 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -16,13 +16,13 @@ import debounce from 'lodash/debounce'; import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import type { TConversation } from 'librechat-data-provider'; +import { logger, validateFiles, cachePreview, getCachedPreview, removePreviewEntry } from '~/utils'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import { useDelayedUploadToast } from './useDelayedUploadToast'; import { processFileForUpload } from '~/utils/heicConverter'; import { useChatContext } from '~/Providers/ChatContext'; import { ephemeralAgentByConvoId } from '~/store'; -import { logger, validateFiles } from '~/utils'; import useClientResize from './useClientResize'; import useUpdateFiles from './useUpdateFiles'; @@ -130,6 +130,11 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil ); setTimeout(() => { + const cachedBlob = getCachedPreview(data.temp_file_id); + if (cachedBlob && data.file_id !== data.temp_file_id) { + cachePreview(data.file_id, cachedBlob); + removePreviewEntry(data.temp_file_id); + } updateFileById( data.temp_file_id, { @@ -260,7 +265,6 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil replaceFile(extendedFile); await startUpload(extendedFile); - URL.revokeObjectURL(preview); }; img.src = preview; }; @@ -301,6 +305,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil try { // Create initial preview with original file const initialPreview = URL.createObjectURL(originalFile); + cachePreview(file_id, initialPreview); // Create initial ExtendedFile to show immediately const initialExtendedFile: ExtendedFile = { @@ -378,6 +383,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil if (finalProcessedFile !== originalFile) { URL.revokeObjectURL(initialPreview); // Clean up original preview const newPreview = URL.createObjectURL(finalProcessedFile); + cachePreview(file_id, newPreview); const updatedExtendedFile: ExtendedFile = { ...initialExtendedFile, diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 9f809bd6c1..325ee97315 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -526,6 +526,23 @@ export default function useEventHandlers({ } else if (requestMessage != null && responseMessage != null) { finalMessages = [...messages, requestMessage, responseMessage]; } + + /* Preserve files from current messages when server response lacks them */ + if (finalMessages.length > 0) { + const currentMsgMap = new Map( + currentMessages + .filter((m) => m.files && m.files.length > 0) + .map((m) => [m.messageId, m.files]), + ); + for (let i = 0; i < finalMessages.length; i++) { + const msg = finalMessages[i]; + const preservedFiles = currentMsgMap.get(msg.messageId); + if (msg.files == null && preservedFiles) { + finalMessages[i] = { ...msg, files: preservedFiles }; + } + } + } + if (finalMessages.length > 0) { setFinalMessages(conversation.conversationId, finalMessages); } else if ( diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 2a7dfc4a88..dae075b471 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -24,6 +24,7 @@ export * from './resources'; export * from './roles'; export * from './localStorage'; export * from './promptGroups'; +export * from './previewCache'; export * from './email'; export * from './share'; export * from './timestamps'; diff --git a/client/src/utils/previewCache.ts b/client/src/utils/previewCache.ts new file mode 100644 index 0000000000..604ce56308 --- /dev/null +++ b/client/src/utils/previewCache.ts @@ -0,0 +1,35 @@ +/** + * Module-level cache for local blob preview URLs keyed by file_id. + * Survives message replacements from SSE but clears on page refresh. + */ +const previewCache = new Map(); + +export function cachePreview(fileId: string, previewUrl: string): void { + const existing = previewCache.get(fileId); + if (existing && existing !== previewUrl) { + URL.revokeObjectURL(existing); + } + previewCache.set(fileId, previewUrl); +} + +export function getCachedPreview(fileId: string): string | undefined { + return previewCache.get(fileId); +} + +/** Removes the cache entry without revoking the blob (used when transferring between keys) */ +export function removePreviewEntry(fileId: string): void { + previewCache.delete(fileId); +} + +export function deletePreview(fileId: string): void { + const url = previewCache.get(fileId); + if (url) { + URL.revokeObjectURL(url); + previewCache.delete(fileId); + } +} + +export function clearPreviewCache(): void { + previewCache.forEach((url) => URL.revokeObjectURL(url)); + previewCache.clear(); +} diff --git a/package-lock.json b/package-lock.json index e634f9f053..f382df6b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -445,7 +445,6 @@ "react-gtm-module": "^2.0.11", "react-hook-form": "^7.43.9", "react-i18next": "^15.4.0", - "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.30.3", @@ -500,6 +499,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", + "monaco-editor": "^0.55.0", "postcss": "^8.4.31", "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", @@ -32359,11 +32359,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" - }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -34312,7 +34307,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -34323,7 +34317,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -34333,7 +34326,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -38142,19 +38134,6 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "node_modules/react-lazy-load-image-component": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.0.tgz", - "integrity": "sha512-8KFkDTgjh+0+PVbH+cx0AgxLGbdTsxWMnxXzU5HEUztqewk9ufQAu8cstjZhyvtMIPsdMcPZfA0WAa7HtjQbBQ==", - "dependencies": { - "lodash.debounce": "^4.0.8", - "lodash.throttle": "^4.1.1" - }, - "peerDependencies": { - "react": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", - "react-dom": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x" - } - }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", From cfaa6337c100aa6f400164cf0373d4a0ffebcaaf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 6 Mar 2026 19:18:35 -0500 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`express-rat?= =?UTF-8?q?e-limit`=20to=20v8.3.0=20(#12115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/package.json b/api/package.json index 7710956701..2f4bca3a0c 100644 --- a/api/package.json +++ b/api/package.json @@ -63,7 +63,7 @@ "eventsource": "^3.0.2", "express": "^5.2.1", "express-mongo-sanitize": "^2.2.0", - "express-rate-limit": "^8.2.1", + "express-rate-limit": "^8.3.0", "express-session": "^1.18.2", "express-static-gzip": "^2.2.0", "file-type": "^18.7.0", diff --git a/package-lock.json b/package-lock.json index f382df6b37..53e0b2e642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "eventsource": "^3.0.2", "express": "^5.2.1", "express-mongo-sanitize": "^2.2.0", - "express-rate-limit": "^8.2.1", + "express-rate-limit": "^8.3.0", "express-session": "^1.18.2", "express-static-gzip": "^2.2.0", "file-type": "^18.7.0", @@ -27109,12 +27109,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -29284,9 +29284,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12"