diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx
index 9ac31beb0d..3bde90ddbb 100644
--- a/client/src/components/Messages/Content/CodeBlock.tsx
+++ b/client/src/components/Messages/Content/CodeBlock.tsx
@@ -1,4 +1,4 @@
-import React, { useRef, useState, useMemo, useEffect } from 'react';
+import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import { InfoIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
@@ -19,6 +19,10 @@ type CodeBlockProps = Pick<
classProp?: string;
};
+interface FloatingCodeBarProps extends CodeBarProps {
+ isVisible: boolean;
+}
+
const CodeBar: React.FC = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
@@ -51,16 +55,14 @@ const CodeBar: React.FC = React.memo(
}
}}
>
- {isCopied ? (
- <>
-
- {error === true ? '' : localize('com_ui_copied')}
- >
- ) : (
- <>
-
- {error === true ? '' : localize('com_ui_copy_code')}
- >
+ {isCopied ? : }
+ {error !== true && (
+
+ {localize('com_ui_copy_code')}
+
+ {isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
+
+
)}
@@ -70,6 +72,75 @@ const CodeBar: React.FC = React.memo(
},
);
+const FloatingCodeBar: React.FC = React.memo(
+ ({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
+ const localize = useLocalize();
+ const [isCopied, setIsCopied] = useState(false);
+ const copyButtonRef = useRef(null);
+
+ const handleCopy = useCallback(() => {
+ const codeString = codeRef.current?.textContent;
+ if (codeString != null) {
+ const wasFocused = document.activeElement === copyButtonRef.current;
+ setIsCopied(true);
+ copy(codeString.trim(), { format: 'text/plain' });
+ if (wasFocused) {
+ requestAnimationFrame(() => {
+ copyButtonRef.current?.focus();
+ });
+ }
+
+ setTimeout(() => {
+ const focusedElement = document.activeElement as HTMLElement | null;
+ setIsCopied(false);
+ requestAnimationFrame(() => {
+ focusedElement?.focus();
+ });
+ }, 3000);
+ }
+ }, [codeRef]);
+
+ return (
+
+ {plugin === true ? (
+
+ ) : (
+ <>
+ {allowExecution === true && (
+
+ )}
+
+ >
+ )}
+
+ );
+ },
+);
+
const CodeBlock: React.FC = ({
lang,
blockIndex,
@@ -80,6 +151,8 @@ const CodeBlock: React.FC = ({
error,
}) => {
const codeRef = useRef(null);
+ const containerRef = useRef(null);
+ const [isBarVisible, setIsBarVisible] = useState(false);
const toolCallsMap = useToolCallsMapContext();
const { messageId, partIndex } = useMessageContext();
const key = allowExecution
@@ -97,6 +170,29 @@ const CodeBlock: React.FC = ({
}
}, [fetchedToolCalls]);
+ // Handle focus within the container (for keyboard navigation)
+ const handleFocus = useCallback(() => {
+ setIsBarVisible(true);
+ }, []);
+
+ const handleBlur = useCallback((e: React.FocusEvent) => {
+ // Check if focus is moving to another element within the container
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
+ setIsBarVisible(false);
+ }
+ }, []);
+
+ const handleMouseEnter = useCallback(() => {
+ setIsBarVisible(true);
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ // Only hide if no element inside has focus
+ if (!containerRef.current?.contains(document.activeElement)) {
+ setIsBarVisible(false);
+ }
+ }, []);
+
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
const next = () => {
@@ -118,7 +214,14 @@ const CodeBlock: React.FC = ({
const language = isNonCode ? 'json' : lang;
return (
-
+
= ({
{codeChildren}
+
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
diff --git a/client/src/components/Messages/Content/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid.tsx
index 02a3086c3c..33e88d4353 100644
--- a/client/src/components/Messages/Content/Mermaid.tsx
+++ b/client/src/components/Messages/Content/Mermaid.tsx
@@ -49,6 +49,8 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => {
const copyButtonRef = useRef(null);
const dialogShowCodeButtonRef = useRef(null);
const dialogCopyButtonRef = useRef(null);
+ const zoomCopyButtonRef = useRef(null);
+ const dialogZoomCopyButtonRef = useRef(null);
// Zoom and pan state
const [zoom, setZoom] = useState(1);
@@ -154,6 +156,30 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => {
});
}, [children]);
+ // Zoom controls copy with focus restoration
+ const [isZoomCopied, setIsZoomCopied] = useState(false);
+ const handleZoomCopy = useCallback(() => {
+ copy(children.trim(), { format: 'text/plain' });
+ setIsZoomCopied(true);
+ requestAnimationFrame(() => {
+ zoomCopyButtonRef.current?.focus();
+ });
+ setTimeout(() => {
+ setIsZoomCopied(false);
+ requestAnimationFrame(() => {
+ zoomCopyButtonRef.current?.focus();
+ });
+ }, 3000);
+ }, [children]);
+
+ // Dialog zoom controls copy
+ const handleDialogZoomCopy = useCallback(() => {
+ copy(children.trim(), { format: 'text/plain' });
+ requestAnimationFrame(() => {
+ dialogZoomCopyButtonRef.current?.focus();
+ });
+ }, [children]);
+
const handleRetry = () => {
setRetryCount((prev) => prev + 1);
};
@@ -392,6 +418,19 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => {
>
+
+
);
@@ -438,6 +477,19 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => {
>
+
+
);
diff --git a/client/src/components/Messages/Content/MermaidErrorBoundary.tsx b/client/src/components/Messages/Content/MermaidErrorBoundary.tsx
new file mode 100644
index 0000000000..a2edca062c
--- /dev/null
+++ b/client/src/components/Messages/Content/MermaidErrorBoundary.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+
+interface MermaidErrorBoundaryProps {
+ children: React.ReactNode;
+ /** The mermaid code to display as fallback */
+ code: string;
+}
+
+interface MermaidErrorBoundaryState {
+ hasError: boolean;
+}
+
+/**
+ * Error boundary specifically for Mermaid diagrams.
+ * Falls back to displaying the raw mermaid code if rendering fails.
+ */
+class MermaidErrorBoundary extends React.Component<
+ MermaidErrorBoundaryProps,
+ MermaidErrorBoundaryState
+> {
+ constructor(props: MermaidErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(): MermaidErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('Mermaid rendering error:', error, errorInfo);
+ }
+
+ componentDidUpdate(prevProps: MermaidErrorBoundaryProps) {
+ // Reset error state if code changes (e.g., user edits the message)
+ if (prevProps.code !== this.props.code && this.state.hasError) {
+ this.setState({ hasError: false });
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+ {'mermaid'}
+
+
+ {this.props.code}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default MermaidErrorBoundary;
diff --git a/client/src/components/Messages/Content/RunCode.tsx b/client/src/components/Messages/Content/RunCode.tsx
index 197865e2b0..020b00703c 100644
--- a/client/src/components/Messages/Content/RunCode.tsx
+++ b/client/src/components/Messages/Content/RunCode.tsx
@@ -86,7 +86,9 @@ const RunCode: React.FC = React.memo(({ lang, codeRef, blockIndex
<>