🔄 refactor: Migrate to react-resizable-panels v4 with Artifacts Header polish (#12356)
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

* chore: Update react-resizable-panels dependency to version 4.7.4

- Upgraded the "react-resizable-panels" package in package-lock.json, package.json, and client package.json files to ensure compatibility with the latest features and improvements.
- Adjusted peer dependencies for React and ReactDOM to align with the new version requirements.

* refactor: Update Share and SidePanel components to `react-resizable-panels` v4

- Refactored the ShareArtifactsContainer to utilize a new layout change handler, enhancing artifact panel resizing functionality.
- Updated ArtifactsPanel to use the new `usePanelRef` hook, improving panel reference management.
- Simplified SidePanelGroup by removing unnecessary layout normalization and integrating default layout handling with localStorage.
- Removed the deprecated `normalizeLayout` utility function to streamline the codebase.
- Adjusted Resizable components to ensure consistent sizing and layout behavior across panels.

* style: Enhance scrollbar appearance across application

- Added custom scrollbar styles to both artifacts and markdown files, improving aesthetics and user experience.
- Implemented dark mode adjustments for scrollbar visibility, ensuring consistency across different color schemes.

* style: Standardize button sizes and layout in Artifacts components

- Updated button dimensions to a consistent height of 9 units across various components including Artifacts, Code, and DownloadArtifact.
- Adjusted padding and layout properties in the Artifacts header for improved visual consistency.
- Enhanced the Radio component to accept a new `buttonClassName` prop for better customization of button styles.

* chore: import order
This commit is contained in:
Danny Avila 2026-03-22 02:21:27 -04:00 committed by GitHub
parent 733a9364c0
commit 676641f3da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 92 additions and 118 deletions

View file

@ -95,7 +95,7 @@
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-resizable-panels": "^4.7.4",
"react-router-dom": "^6.30.3",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",

View file

@ -216,7 +216,7 @@ export default function Artifacts() {
{/* Header */}
<div
className={cn(
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
'flex h-[52px] flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt p-2 transition-all duration-300',
isMobile ? 'justify-center' : 'overflow-hidden',
)}
>
@ -234,6 +234,7 @@ export default function Artifacts() {
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
buttonClassName="h-9 px-3 gap-1.5"
/>
</div>
)}
@ -249,6 +250,7 @@ export default function Artifacts() {
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={localize('com_ui_refresh')}
@ -284,6 +286,7 @@ export default function Artifacts() {
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={closeArtifacts}
aria-label={localize('com_ui_close')}
>

View file

@ -40,6 +40,7 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={handleCopy}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
>

View file

@ -38,6 +38,7 @@ const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={handleDownload}
aria-label={localize('com_ui_download_artifact')}
>

View file

@ -83,14 +83,9 @@ export function ShareArtifactsContainer({
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));
/**
* Handles artifact panel resize and persists size to localStorage
*/
const handleLayoutChange = (sizes: number[]) => {
if (sizes.length < 2) {
return;
}
const newSize = sizes[1];
const handleLayoutChanged = (layout: Record<string, number | string>) => {
const raw = layout['share-artifacts'];
const newSize = typeof raw === 'string' ? parseFloat(raw) : raw;
if (!Number.isFinite(newSize)) {
return;
}
@ -115,24 +110,22 @@ export function ShareArtifactsContainer({
return (
<ResizablePanelGroup
direction="horizontal"
className="flex h-full w-full"
onLayout={handleLayoutChange}
orientation="horizontal"
className="h-full w-full"
onLayoutChanged={handleLayoutChanged}
>
<ResizablePanel
defaultSize={100 - normalizedArtifactSize}
minSize={35}
order={1}
defaultSize={`${100 - normalizedArtifactSize}`}
minSize="35"
id="share-content"
>
{mainContent}
</ResizablePanel>
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={normalizedArtifactSize}
minSize={20}
maxSize={60}
order={2}
defaultSize={`${normalizedArtifactSize}`}
minSize="20"
maxSize="60"
id="share-artifacts"
>
<ShareArtifactsPanel contextValue={artifactsContextValue} />

View file

@ -1,27 +1,21 @@
import { useRef, useEffect, memo } from 'react';
import { useEffect, memo } from 'react';
import { usePanelRef } from 'react-resizable-panels';
import { ResizableHandleAlt, ResizablePanel } from '@librechat/client';
import type { ImperativePanelHandle } from 'react-resizable-panels';
interface ArtifactsPanelProps {
artifacts: React.ReactNode | null;
currentLayout: number[];
minSizeMain: number;
minSizeMain: string;
shouldRender: boolean;
onRenderChange: (shouldRender: boolean) => void;
}
/**
* ArtifactsPanel component - memoized to prevent unnecessary re-renders
* Only re-renders when artifacts visibility or layout changes
*/
const ArtifactsPanel = memo(function ArtifactsPanel({
artifacts,
currentLayout,
minSizeMain,
shouldRender,
onRenderChange,
}: ArtifactsPanelProps) {
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
const artifactsPanelRef = usePanelRef();
useEffect(() => {
if (artifacts != null) {
@ -34,7 +28,7 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
} else if (shouldRender) {
onRenderChange(false);
}
}, [artifacts, shouldRender, onRenderChange]);
}, [artifacts, shouldRender, onRenderChange, artifactsPanelRef]);
if (!shouldRender) {
return null;
@ -46,13 +40,12 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
)}
<ResizablePanel
ref={artifactsPanelRef}
defaultSize={artifacts != null ? currentLayout[1] : 0}
minSize={minSizeMain}
maxSize={70}
defaultSize="50"
maxSize="70"
collapsedSize="0"
collapsible={true}
collapsedSize={0}
order={2}
minSize={minSizeMain}
panelRef={artifactsPanelRef}
id="artifacts-panel"
>
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>

View file

@ -1,8 +1,10 @@
import { useState, useEffect, useMemo, memo } from 'react';
import throttle from 'lodash/throttle';
import { useState, memo } from 'react';
import { useDefaultLayout } from 'react-resizable-panels';
import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/client';
import ArtifactsPanel from './ArtifactsPanel';
import { normalizeLayout } from '~/utils';
const PANEL_IDS_SINGLE = ['messages-view'];
const PANEL_IDS_SPLIT = ['messages-view', 'artifacts-panel'];
interface SidePanelProps {
artifacts?: React.ReactNode;
@ -13,46 +15,29 @@ const SidePanelGroup = memo(({ artifacts, children }: SidePanelProps) => {
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const currentLayout = useMemo(() => {
if (artifacts == null) {
return [100];
}
return normalizeLayout([50, 50]);
}, [artifacts]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'side-panel-layout',
panelIds: artifacts != null ? PANEL_IDS_SPLIT : PANEL_IDS_SINGLE,
storage: localStorage,
});
const throttledSaveLayout = useMemo(
() =>
throttle((sizes: number[]) => {
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
useEffect(() => () => throttledSaveLayout.cancel(), [throttledSaveLayout]);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
const minSizeMain = artifacts != null ? '15' : '30';
return (
<>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
className="relative flex-1 bg-presentation"
>
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
>
<ResizablePanel defaultSize="50" minSize={minSizeMain} id="messages-view">
{children}
</ResizablePanel>
{!isSmallScreen && (
<ArtifactsPanel
artifacts={artifacts}
currentLayout={currentLayout}
minSizeMain={minSizeMain}
shouldRender={shouldRenderArtifacts}
onRenderChange={setShouldRenderArtifacts}

View file

@ -189,6 +189,14 @@ export const sharedFiles = {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<style>
::-webkit-scrollbar{height:.1em;width:.5rem}
::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,.1);border-radius:9999px}
::-webkit-scrollbar-track{background-color:transparent;border-radius:9999px}
@media(prefers-color-scheme:dark){::-webkit-scrollbar-thumb{background-color:hsla(0,0%,100%,.1)}}
*{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.1) transparent}
@media(prefers-color-scheme:dark){*{scrollbar-color:hsla(0,0%,100%,.1) transparent}}
</style>
</head>
<body>
<div id="root"></div>

View file

@ -113,24 +113,6 @@ export const extractContent = (
return '';
};
export const normalizeLayout = (layout: number[]) => {
const sum = layout.reduce((acc, size) => acc + size, 0);
if (Math.abs(sum - 100) < 0.01) {
return layout.map((size) => Number(size.toFixed(2)));
}
const factor = 100 / sum;
const normalizedLayout = layout.map((size) => Number((size * factor).toFixed(2)));
const adjustedSum = normalizedLayout.reduce(
(acc, size, index) => (index === layout.length - 1 ? acc : acc + size),
0,
);
normalizedLayout[normalizedLayout.length - 1] = Number((100 - adjustedSum).toFixed(2));
return normalizedLayout;
};
export const handleUIAction = async (result: UIActionResult, ask: TAskFunction) => {
const supportedTypes = ['intent', 'tool', 'prompt'];

View file

@ -211,6 +211,18 @@ const markdownCSS = `
background-color: #21262d;
}
}
/* Scrollbar */
::-webkit-scrollbar { height: 0.1em; width: 0.5rem; }
::-webkit-scrollbar-thumb { background-color: rgba(0,0,0,0.1); border-radius: 9999px; }
::-webkit-scrollbar-track { background-color: transparent; border-radius: 9999px; }
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb { background-color: hsla(0,0%,100%,0.1); }
}
* { scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.1) transparent; }
@media (prefers-color-scheme: dark) {
* { scrollbar-color: hsla(0,0%,100%,0.1) transparent; }
}
`;
/**

14
package-lock.json generated
View file

@ -461,7 +461,7 @@
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-resizable-panels": "^4.7.4",
"react-router-dom": "^6.30.3",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@ -38086,13 +38086,13 @@
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.7.4.tgz",
"integrity": "sha512-1sehMbUJxZFj4imu1TuH+RS7Xe5Jo+3HyxBYafCEdxPmDeLAQNmKEByyxCQRl7xCLa5cLXSJ9T8acQyyiPsNdQ==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-router": {
@ -44131,7 +44131,7 @@
"react-dom": "^18.2.0 || ^19.1.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.4.0 || ^15.6.0",
"react-resizable-panels": "^3.0.6",
"react-resizable-panels": "^4.7.4",
"react-textarea-autosize": "^8.4.0",
"tailwind-merge": "^1.9.1"
}

View file

@ -71,7 +71,7 @@
"react-dom": "^18.2.0 || ^19.1.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.4.0 || ^15.6.0",
"react-resizable-panels": "^3.0.6",
"react-resizable-panels": "^4.7.4",
"react-textarea-autosize": "^8.4.0",
"tailwind-merge": "^1.9.1"
},

View file

@ -13,6 +13,7 @@ interface RadioProps {
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
buttonClassName?: string;
fullWidth?: boolean;
'aria-labelledby'?: string;
}
@ -23,6 +24,7 @@ const Radio = memo(function Radio({
onChange,
disabled = false,
className = '',
buttonClassName = '',
fullWidth = false,
'aria-labelledby': ariaLabelledBy,
}: RadioProps) {
@ -45,7 +47,7 @@ const Radio = memo(function Radio({
if (selectedButton && container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
const offsetLeft = buttonRect.left - containerRect.left - 4;
const offsetLeft = buttonRect.left - containerRect.left;
setBackgroundStyle({
width: `${buttonRect.width}px`,
transform: `translateX(${offsetLeft}px)`,
@ -94,13 +96,13 @@ const Radio = memo(function Radio({
return (
<div
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted p-1 ${className}`}
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted ${className}`}
role="radiogroup"
aria-labelledby={ariaLabelledBy}
>
{selectedIndex >= 0 && isMounted && (
<div
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
className="pointer-events-none absolute inset-y-0 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
style={backgroundStyle}
/>
)}
@ -117,7 +119,7 @@ const Radio = memo(function Radio({
disabled={disabled}
className={`relative z-10 flex h-[34px] items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
currentValue === option.value ? 'text-foreground' : 'text-foreground'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${fullWidth ? 'flex-1' : ''}`}
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${fullWidth ? 'flex-1' : ''} ${buttonClassName}`}
>
{option.icon && (
<span className="flex-shrink-0" aria-hidden="true">

View file

@ -1,30 +1,24 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { Group, Panel, Separator } from 'react-resizable-panels';
import type { ComponentProps } from 'react';
import { cn } from '~/utils';
const ResizablePanelGroup = ({
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
const ResizablePanelGroup = ({ className = '', ...props }: ComponentProps<typeof Group>) => (
<Group className={cn('h-full w-full', className)} {...props} />
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle,
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
<Separator
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
{...props}
@ -34,29 +28,29 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</Separator>
);
const ResizableHandleAlt = ({
withHandle,
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
<Separator
className={cn(
'group relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
'group relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
{...props}
>
{withHandle && (
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible group-data-[resize-handle-active]:visible">
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible group-data-[separator=active]:visible">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</Separator>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle, ResizableHandleAlt };