feat: Enhance artifact panel animations and improve UI responsiveness

- Updated Thinking component button styles for smoother transitions.
- Implemented dynamic rendering for artifacts panel with animation effects.
- Refactored localization keys for consistency across multiple languages.
- Added new CSS animations for iOS-inspired smooth transitions.
- Improved Tailwind CSS configuration to support enhanced animation effects.
This commit is contained in:
Marco Beretta 2025-10-24 11:08:57 +02:00
parent 44fa479bd4
commit 7e1d02bcc3
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
35 changed files with 416 additions and 104 deletions

View file

@ -55,40 +55,52 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return (
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
<button
type="button"
onClick={() => {
if (!location.pathname.includes('/c/')) {
{(() => {
const handleClick = () => {
if (!location.pathname.includes('/c/')) return;
if (isSelected) {
resetCurrentArtifactId();
setVisible(false);
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);
}}
className={
`relative overflow-hidden rounded-xl transition-all duration-200 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg ` +
};
const buttonClass =
`relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98] ` +
(isSelected
? 'border-border-medium bg-surface-hover shadow-lg'
: 'border-border-light bg-surface-tertiary shadow-sm')
}
>
<div className="w-fit p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">
{localize('com_ui_artifact_click')}
: 'border-border-light bg-surface-tertiary shadow-sm');
const actionLabel = isSelected
? localize('com_ui_click_to_close')
: localize('com_ui_click_to_open');
return (
<button type="button" onClick={handleClick} className={buttonClass}>
<div className="w-fit p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">{actionLabel}</div>
</div>
</div>
</div>
</div>
</div>
</button>
</button>
);
})()}
<br />
</div>
);

View file

@ -1,6 +1,10 @@
import React, { memo, useMemo, type MutableRefObject } from 'react';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackProviderProps, SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
import type {
SandpackProviderProps,
SandpackPreviewRef,
PreviewProps,
} from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';

View file

@ -39,12 +39,16 @@ export default function ArtifactTabs({
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<>
<div className="flex h-full w-full flex-col">
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className={cn('flex-grow overflow-auto')}
className={cn(
'h-full w-full flex-grow overflow-auto',
'data-[state=active]:duration-200 data-[state=active]:animate-in data-[state=active]:fade-in-0',
'data-[state=inactive]:duration-150 data-[state=inactive]:animate-out data-[state=inactive]:fade-out-0',
)}
tabIndex={-1}
>
<ArtifactCodeEditor
@ -56,7 +60,16 @@ export default function ArtifactTabs({
sharedProps={sharedProps}
/>
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<Tabs.Content
value="preview"
className={cn(
'h-full w-full flex-grow overflow-auto',
'data-[state=active]:duration-200 data-[state=active]:animate-in data-[state=active]:fade-in-0',
'data-[state=inactive]:duration-150 data-[state=inactive]:animate-out data-[state=inactive]:fade-out-0',
)}
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}
@ -67,6 +80,6 @@ export default function ArtifactTabs({
startupConfig={startupConfig}
/>
</Tabs.Content>
</>
</div>
);
}

View file

@ -50,6 +50,7 @@ export default function ArtifactVersion({
return (
<DropdownPopup
menuId={menuId}
portal
focusLoop
unmountOnHide
isOpen={isPopoverActive}

View file

@ -1,7 +1,7 @@
import { useRef, useState, useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
@ -21,12 +21,49 @@ export default function Artifacts() {
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [height, setHeight] = useState(90); // Height as percentage of viewport
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0); // Dynamic blur amount
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
useEffect(() => {
setIsVisible(true);
}, []);
setIsMounted(true);
const delay = isMobile ? 50 : 30;
const timer = setTimeout(() => setIsVisible(true), delay);
return () => {
clearTimeout(timer);
setIsMounted(false);
};
}, [isMobile]);
// Dynamic blur based on height - more blur when taking up more screen
useEffect(() => {
if (!isMobile) {
setBlurAmount(0);
return;
}
// Calculate blur amount based on how much screen is covered
// 50% height = no blur, 100% height = full blur
const minHeightForBlur = 50;
const maxHeightForBlur = 100;
if (height <= minHeightForBlur) {
setBlurAmount(0);
} else if (height >= maxHeightForBlur) {
setBlurAmount(32); // Increased from 16 to 32 for stronger blur
} else {
// Linear interpolation between 0 and 32px blur
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
setBlurAmount(Math.round(progress * 32)); // Changed from 16 to 32
}
}, [height, isMobile]);
const {
activeTab,
@ -37,7 +74,47 @@ export default function Artifacts() {
setCurrentArtifactId,
} = useArtifacts();
if (!currentArtifact) {
const handleDragStart = (e: React.PointerEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handleDragMove = (e: React.PointerEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const viewportHeight = window.innerHeight;
const deltaPercentage = (deltaY / viewportHeight) * 100;
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
setHeight(newHeight);
};
const handleDragEnd = (e: React.PointerEvent) => {
if (!isDragging) return;
setIsDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
// Snap to positions based on final height
if (height < 30) {
// Close if dragged down significantly
closeArtifacts();
} else if (height > 95) {
// Snap to full height if dragged near top
setHeight(100);
} else if (height < 60) {
// Snap to minimum if in lower range
setHeight(50);
} else {
// Snap to default
setHeight(90);
}
};
if (!currentArtifact || !isMounted) {
return null;
}
@ -51,57 +128,97 @@ export default function Artifacts() {
};
const closeArtifacts = () => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), isMobile ? 400 : 500);
if (isMobile) {
setIsClosing(true);
setIsVisible(false);
setTimeout(() => {
setArtifactsVisible(false);
setIsClosing(false);
setHeight(90); // Reset height
}, 250);
} else {
resetCurrentArtifactId();
setArtifactsVisible(false);
}
};
// Calculate backdrop opacity based on blur amount
const backdropOpacity = blurAmount > 0 ? Math.min(0.3, blurAmount / 53.33) : 0;
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
<div className="flex h-full w-full flex-col">
{/* Mobile backdrop */}
{/* Mobile backdrop with dynamic blur */}
{isMobile && (
<div
className={cn(
'duration-400 ease-[cubic-bezier(0.25,0.46,0.45,0.94)] fixed inset-0 z-[99] bg-black/40 backdrop-blur-md transition-opacity',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
isVisible && !isClosing
? 'transition-all duration-300'
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
// Allow pointer events when not fully blurred so chat is scrollable
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
)}
onClick={closeArtifacts}
style={{
opacity: isVisible && !isClosing ? backdropOpacity : 0,
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
}}
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
aria-hidden="true"
/>
)}
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden bg-surface-primary text-xl text-text-primary',
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
isMobile
? cn(
'duration-400 ease-[cubic-bezier(0.25,0.46,0.45,0.94)] fixed inset-x-0 bottom-0 z-[100] h-[90vh] rounded-t-[20px] shadow-[0_-10px_40px_rgba(0,0,0,0.3)] transition-transform will-change-transform',
isVisible ? 'translate-y-0' : 'translate-y-full',
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
isVisible && !isClosing
? 'translate-y-0 opacity-100'
: 'duration-250 translate-y-full opacity-0 transition-all',
isDragging ? '' : 'transition-all duration-300',
)
: cn(
'ease-[cubic-bezier(0.25,0.46,0.45,0.94)] shadow-xl transition-all duration-500 will-change-transform',
isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0',
'h-full shadow-2xl',
isVisible && !isClosing
? 'duration-350 translate-x-0 opacity-100 transition-all'
: 'translate-x-5 opacity-0 transition-all duration-300',
),
)}
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
>
{/* Mobile drag indicator */}
{isMobile && (
<div className="flex flex-shrink-0 items-center justify-center pb-1.5 pt-2">
<div className="h-1 w-10 rounded-full bg-border-medium opacity-50" />
<div
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
</div>
)}
{/* Header */}
<div
className={cn(
'flex flex-shrink-0 items-center justify-between gap-2 overflow-x-auto border-b border-border-light bg-surface-primary-alt px-3 py-2',
isMobile && 'justify-center',
'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',
isMobile ? 'justify-center' : 'overflow-hidden',
)}
>
{!isMobile && (
<div className="flex items-center">
<div
className={cn(
'flex items-center transition-all duration-500',
isVisible && !isClosing
? 'translate-x-0 opacity-100'
: '-translate-x-2 opacity-0',
)}
>
<Tabs.List className="relative inline-flex h-9 gap-2 rounded-xl bg-surface-tertiary p-0.5">
<div
className={cn(
'absolute top-0.5 h-8 rounded-xl bg-surface-primary-alt transition-transform duration-200 ease-out',
'duration-[350ms] absolute top-0.5 h-8 rounded-[10px] bg-surface-primary-alt shadow-sm transition-all will-change-transform',
activeTab === 'code'
? 'w-[42%] translate-x-0'
: 'w-[50%] translate-x-[calc(100%-0.250rem)]',
@ -109,24 +226,30 @@ export default function Artifacts() {
/>
<Tabs.Trigger
value="code"
className="relative z-10 flex items-center gap-1.5 rounded-xl border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 ease-out hover:text-text-primary data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
className="relative z-10 flex items-center gap-1.5 rounded-[10px] border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 hover:text-text-primary active:scale-95 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Code className="size-3" />
<span>{localize('com_ui_code')}</span>
<Code className="size-3 transition-transform duration-200 group-active:scale-90" />
<span className="whitespace-nowrap">{localize('com_ui_code')}</span>
</Tabs.Trigger>
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="relative z-10 flex items-center gap-1.5 rounded-xl border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 ease-out hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
className="relative z-10 flex items-center gap-1.5 rounded-[10px] border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 hover:text-text-primary active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Play className="size-3" />
<span>{localize('com_ui_preview')}</span>
<Play className="size-3 transition-transform duration-200 group-active:scale-90" />
<span className="whitespace-nowrap">{localize('com_ui_preview')}</span>
</Tabs.Trigger>
</Tabs.List>
</div>
)}
<div className="flex min-w-max items-center gap-2">
<div
className={cn(
'flex items-center gap-2 transition-all duration-500',
isMobile ? 'min-w-max' : '',
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
)}
>
{activeTab === 'preview' && (
<Button
size="icon"
@ -134,9 +257,13 @@ export default function Artifacts() {
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={localize('com_ui_refresh')}
className="h-8 w-8 transition-transform duration-150 ease-out hover:scale-105 active:scale-95"
className="transition-all duration-150 active:scale-90"
>
{isRefreshing ? <Spinner size={16} /> : <RefreshCw size={16} />}
{isRefreshing ? (
<Spinner size={16} />
) : (
<RefreshCw size={16} className="transition-transform duration-200" />
)}
</Button>
)}
{activeTab !== 'preview' && isMutating && (
@ -161,35 +288,37 @@ export default function Artifacts() {
variant="ghost"
onClick={closeArtifacts}
aria-label={localize('com_ui_close')}
className="h-8 w-8 transition-transform duration-150 ease-out hover:scale-105 active:scale-95"
className="h-8 w-8 transition-all duration-150 hover:scale-105 active:scale-90"
>
<X size={16} />
</Button>
</div>
</div>
{/* Content Area - This is the key fix */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
{/* Content Area - Fixed positioning to prevent layout shifts */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
</div>
</div>
{/* Mobile Tab Switcher */}
{/* Mobile Tab Switcher with iOS-style animation */}
{isMobile && (
<div className="pb-safe-offset-3 flex-shrink-0 border-t border-border-light bg-surface-primary-alt px-3 pt-2">
<Tabs.List className="relative flex h-10 w-full rounded-xl bg-surface-tertiary p-1">
<div
className={cn(
'duration-[250ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] absolute left-1 top-1 h-8 w-[calc(50%-0.25rem)] rounded-lg bg-surface-primary-alt shadow-sm transition-transform',
'duration-[350ms] absolute left-1 top-1 h-8 w-[calc(50%-0.25rem)] rounded-[10px] bg-surface-primary-alt shadow-sm transition-transform will-change-transform',
activeTab === 'code' ? 'translate-x-0' : 'translate-x-[calc(100%+0.5rem)]',
)}
/>
<Tabs.Trigger
value="code"
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-lg border-transparent py-1.5 text-xs font-medium transition-all duration-150 ease-out active:scale-95 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-[10px] border-transparent py-1.5 text-xs font-medium transition-all duration-150 active:scale-95 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Code className="size-3.5" />
<span>{localize('com_ui_code')}</span>
@ -197,7 +326,7 @@ export default function Artifacts() {
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-lg border-transparent py-1.5 text-xs font-medium transition-all duration-150 ease-out active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-[10px] border-transparent py-1.5 text-xs font-medium transition-all duration-150 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Play className="size-3.5" />
<span>{localize('com_ui_preview')}</span>

View file

@ -7,8 +7,8 @@ import { cn } from '~/utils';
import store from '~/store';
const BUTTON_STYLES = {
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
base: '-sring group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] transition-all duration-300 hover:bg-surface-secondary active:scale-95 animate-thinking-appear',
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-300',
} as const;
const CONTENT_STYLES = {
@ -69,7 +69,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
</div>
<div
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
className={cn('duration-400 grid transition-all', isExpanded && 'mb-8')}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}

View file

@ -14,6 +14,8 @@ import { normalizeLayout } from '~/utils';
import SidePanel from './SidePanel';
import store from '~/store';
const ANIMATION_DURATION = 500;
interface SidePanelProps {
defaultLayout?: number[] | undefined;
defaultCollapsed?: boolean;
@ -42,14 +44,43 @@ const SidePanelGroup = memo(
);
const panelRef = useRef<ImperativePanelHandle>(null);
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
const [minSize, setMinSize] = useState(defaultMinSize);
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
const artifactsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const hideSidePanel = useRecoilValue(store.hideSidePanel);
useEffect(() => {
if (artifacts != null) {
if (artifactsTimeoutRef.current) {
clearTimeout(artifactsTimeoutRef.current);
artifactsTimeoutRef.current = null;
}
setShouldRenderArtifacts(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
artifactsPanelRef.current?.expand();
});
});
} else if (shouldRenderArtifacts) {
artifactsPanelRef.current?.collapse();
artifactsTimeoutRef.current = setTimeout(() => {
setShouldRenderArtifacts(false);
}, ANIMATION_DURATION);
}
return () => {
if (artifactsTimeoutRef.current) {
clearTimeout(artifactsTimeoutRef.current);
}
};
}, [artifacts, shouldRenderArtifacts]);
const calculateLayout = useCallback(() => {
if (artifacts == null) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
@ -120,20 +151,25 @@ const SidePanelGroup = memo(
>
{children}
</ResizablePanel>
{artifacts != null && !isSmallScreen && (
{shouldRenderArtifacts && !isSmallScreen && (
<>
<ResizableHandleAlt
withHandle
className="ml-3 bg-border-medium text-text-primary transition-opacity duration-300"
/>
{artifacts != null && (
<ResizableHandleAlt
withHandle
className="ml-3 bg-border-medium text-text-primary"
/>
)}
<ResizablePanel
defaultSize={currentLayout[1]}
ref={artifactsPanelRef}
defaultSize={artifacts != null ? currentLayout[1] : 0}
minSize={minSizeMain}
collapsible={true}
collapsedSize={0}
order={2}
id="artifacts-panel"
className="ease-[cubic-bezier(0.25,0.46,0.45,0.94)] transition-all duration-500"
>
{artifacts}
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
</ResizablePanel>
</>
)}