♻️ refactor: SidePanel Context to Optimize on ChatView Rerender (#8509)

This commit is contained in:
Danny Avila 2025-07-17 11:31:19 -04:00 committed by GitHub
parent 0b1b0af741
commit dfef7c31d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 139 deletions

View file

@ -0,0 +1,31 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
interface SidePanelContextValue {
endpoint?: EModelEndpoint | null;
}
const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined);
export function SidePanelProvider({ children }: { children: React.ReactNode }) {
const { conversation } = useChatContext();
/** Context value only created when endpoint changes */
const contextValue = useMemo<SidePanelContextValue>(
() => ({
endpoint: conversation?.endpoint,
}),
[conversation?.endpoint],
);
return <SidePanelContext.Provider value={contextValue}>{children}</SidePanelContext.Provider>;
}
export function useSidePanelContext() {
const context = useContext(SidePanelContext);
if (!context) {
throw new Error('useSidePanelContext must be used within SidePanelProvider');
}
return context;
}

View file

@ -24,4 +24,5 @@ export * from './ToolCallsMapContext';
export * from './SetConvoContext';
export * from './SearchContext';
export * from './BadgeRowContext';
export * from './SidePanelContext';
export { default as BadgeRowProvider } from './BadgeRowContext';

View file

@ -4,10 +4,10 @@ import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, SidePanelProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanelGroup } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
import { EditorProvider } from '~/Providers';
import store from '~/store';
export default function Presentation({ children }: { children: React.ReactNode }) {
@ -59,22 +59,24 @@ export default function Presentation({ children }: { children: React.ReactNode }
return (
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>
) : null
}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}
</main>
</SidePanelGroup>
<SidePanelProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>
) : null
}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}
</main>
</SidePanelGroup>
</SidePanelProvider>
</DragDropWrapper>
);
}

View file

@ -7,8 +7,8 @@ import { useMediaQuery, useLocalStorage, useLocalize } from '~/hooks';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useGetEndpointsQuery } from '~/data-provider';
import NavToggle from '~/components/Nav/NavToggle';
import { useSidePanelContext } from '~/Providers';
import { cn, getEndpointField } from '~/utils';
import { useChatContext } from '~/Providers';
import Nav from './Nav';
const defaultMinSize = 20;
@ -43,13 +43,13 @@ const SidePanel = ({
interfaceConfig: TInterfaceConfig;
}) => {
const localize = useLocalize();
const { endpoint } = useSidePanelContext();
const [isHovering, setIsHovering] = useState(false);
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { conversation } = useChatContext();
const { endpoint } = conversation ?? {};
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const defaultActive = useMemo(() => {

View file

@ -22,132 +22,139 @@ interface SidePanelProps {
const defaultMinSize = 20;
const defaultInterface = getConfigDefaults().interface;
const SidePanelGroup = ({
defaultLayout = [97, 3],
defaultCollapsed = false,
fullPanelCollapse = false,
navCollapsedSize = 3,
artifacts,
children,
}: SidePanelProps) => {
const { data: startupConfig } = useGetStartupConfig();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const SidePanelGroup = memo(
({
defaultLayout = [97, 3],
defaultCollapsed = false,
fullPanelCollapse = false,
navCollapsedSize = 3,
artifacts,
children,
}: SidePanelProps) => {
const { data: startupConfig } = useGetStartupConfig();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const panelRef = useRef<ImperativePanelHandle>(null);
const [minSize, setMinSize] = useState(defaultMinSize);
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const panelRef = useRef<ImperativePanelHandle>(null);
const [minSize, setMinSize] = useState(defaultMinSize);
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const calculateLayout = useCallback(() => {
if (artifacts == null) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
return [100 - navSize, navSize];
} else {
const navSize = 0;
const remainingSpace = 100 - navSize;
const newMainSize = Math.floor(remainingSpace / 2);
const artifactsSize = remainingSpace - newMainSize;
return [newMainSize, artifactsSize, navSize];
}
}, [artifacts, defaultLayout]);
const calculateLayout = useCallback(() => {
if (artifacts == null) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
return [100 - navSize, navSize];
} else {
const navSize = 0;
const remainingSpace = 100 - navSize;
const newMainSize = Math.floor(remainingSpace / 2);
const artifactsSize = remainingSpace - newMainSize;
return [newMainSize, artifactsSize, navSize];
}
}, [artifacts, defaultLayout]);
const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]);
const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]);
const throttledSaveLayout = useMemo(
() =>
throttle((sizes: number[]) => {
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
const throttledSaveLayout = useMemo(
() =>
throttle((sizes: number[]) => {
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
useEffect(() => {
if (isSmallScreen) {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
useEffect(() => {
if (isSmallScreen) {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
return;
} else {
setIsCollapsed(defaultCollapsed);
setCollapsedSize(navCollapsedSize);
setMinSize(defaultMinSize);
}
}, [isSmallScreen, defaultCollapsed, navCollapsedSize, fullPanelCollapse]);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
/** Memoized close button handler to prevent re-creating it */
const handleClosePanel = useCallback(() => {
setIsCollapsed(() => {
localStorage.setItem('fullPanelCollapse', 'true');
setFullCollapse(true);
setCollapsedSize(0);
setMinSize(0);
return false;
});
panelRef.current?.collapse();
return;
} else {
setIsCollapsed(defaultCollapsed);
setCollapsedSize(navCollapsedSize);
setMinSize(defaultMinSize);
}
}, [isSmallScreen, defaultCollapsed, navCollapsedSize, fullPanelCollapse]);
}, []);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
return (
<>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
>
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
return (
<>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
>
{children}
</ResizablePanel>
{artifacts != null && (
<>
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={currentLayout[1]}
minSize={minSizeMain}
order={2}
id="artifacts-panel"
>
{artifacts}
</ResizablePanel>
</>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<SidePanel
panelRef={panelRef}
minSize={minSize}
setMinSize={setMinSize}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
collapsedSize={collapsedSize}
setCollapsedSize={setCollapsedSize}
fullCollapse={fullCollapse}
setFullCollapse={setFullCollapse}
defaultSize={currentLayout[currentLayout.length - 1]}
hasArtifacts={artifacts != null}
interfaceConfig={interfaceConfig}
/>
)}
</ResizablePanelGroup>
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={() => {
setIsCollapsed(() => {
localStorage.setItem('fullPanelCollapse', 'true');
setFullCollapse(true);
setCollapsedSize(0);
setMinSize(0);
return false;
});
panelRef.current?.collapse();
}}
/>
</>
);
};
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
>
{children}
</ResizablePanel>
{artifacts != null && (
<>
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={currentLayout[1]}
minSize={minSizeMain}
order={2}
id="artifacts-panel"
>
{artifacts}
</ResizablePanel>
</>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<SidePanel
panelRef={panelRef}
minSize={minSize}
setMinSize={setMinSize}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
collapsedSize={collapsedSize}
setCollapsedSize={setCollapsedSize}
fullCollapse={fullCollapse}
setFullCollapse={setFullCollapse}
defaultSize={currentLayout[currentLayout.length - 1]}
hasArtifacts={artifacts != null}
interfaceConfig={interfaceConfig}
/>
)}
</ResizablePanelGroup>
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
</>
);
},
);
export default memo(SidePanelGroup);
SidePanelGroup.displayName = 'SidePanelGroup';
export default SidePanelGroup;