📋 feat: Add Floating Copy Button to Code Blocks (#11113)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* feat: Add MermaidErrorBoundary for handling rendering errors in Mermaid diagrams

* feat: Implement FloatingCodeBar for enhanced code block interaction and copy functionality

* feat: Add zoom-level bar copy functionality to Mermaid component

* feat: Enhance button styles in FloatingCodeBar and RunCode components for improved user interaction

* refactor: copy button rendering in CodeBar and FloatingCodeBar for improved accessibility and clarity

* chore: linting

* chore: import order
This commit is contained in:
Danny Avila 2025-12-26 20:56:06 -05:00 committed by GitHub
parent 3503b7caeb
commit daed6d9c0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 244 additions and 14 deletions

View file

@ -2,6 +2,7 @@ import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import Mermaid from '~/components/Messages/Content/Mermaid';
import useHasAccess from '~/hooks/Roles/useHasAccess';
@ -39,7 +40,11 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
return <>{children}</>;
} else if (isMermaid) {
const content = typeof children === 'string' ? children : String(children);
return <Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>;
return (
<MermaidErrorBoundary code={content}>
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
</MermaidErrorBoundary>
);
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>

View file

@ -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<CodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
@ -51,16 +55,14 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
}
}}
>
{isCopied ? (
<>
<CheckMark className="h-[18px] w-[18px]" />
{error === true ? '' : localize('com_ui_copied')}
</>
) : (
<>
<Clipboard />
{error === true ? '' : localize('com_ui_copy_code')}
</>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</div>
@ -70,6 +72,75 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
},
);
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(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 (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
className={cn(
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</>
)}
</div>
);
},
);
const CodeBlock: React.FC<CodeBlockProps> = ({
lang,
blockIndex,
@ -80,6 +151,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
error,
}) => {
const codeRef = useRef<HTMLElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isBarVisible, setIsBarVisible] = useState(false);
const toolCallsMap = useToolCallsMapContext();
const { messageId, partIndex } = useMessageContext();
const key = allowExecution
@ -97,6 +170,29 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
}
}, [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<CodeBlockProps> = ({
const language = isNonCode ? 'json' : lang;
return (
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
<div
ref={containerRef}
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<CodeBar
lang={lang}
error={error}
@ -137,6 +240,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
{codeChildren}
</code>
</div>
<FloatingCodeBar
lang={lang}
error={error}
codeRef={codeRef}
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
isVisible={isBarVisible}
/>
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
<div className="bg-gray-700 p-4 text-xs">

View file

@ -49,6 +49,8 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const copyButtonRef = useRef<HTMLButtonElement>(null);
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
// Zoom and pan state
const [zoom, setZoom] = useState(1);
@ -154,6 +156,30 @@ const Mermaid: React.FC<MermaidProps> = 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<MermaidProps> = memo(({ children, id, theme }) => {
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={zoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
@ -438,6 +477,19 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={dialogZoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
<Clipboard className="h-4 w-4" />
</button>
</div>
);

View file

@ -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 (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
{'mermaid'}
</div>
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
{this.props.code}
</pre>
</div>
);
}
return this.props.children;
}
}
export default MermaidErrorBoundary;

View file

@ -86,7 +86,9 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
<>
<button
type="button"
className={cn('ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white')}
className={cn(
'ml-auto flex gap-2 rounded-sm px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
)}
onClick={debouncedExecute}
disabled={execute.isLoading}
>