feat(frontend): Animate conversation panels (#11099)

This commit is contained in:
sp.wack
2025-09-24 20:41:19 +04:00
committed by GitHub
parent df1c5bbf85
commit 15b4690ebf
10 changed files with 195 additions and 156 deletions

View File

@@ -48,7 +48,6 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^3.0.6",
"react-router": "^7.9.1",
"react-syntax-highlighter": "^15.6.6",
"remark-breaks": "^4.0.0",
@@ -6813,38 +6812,6 @@
"node": ">=0.10.0"
}
},
"node_modules/@vitejs/plugin-rsc": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-rsc/-/plugin-rsc-0.4.11.tgz",
"integrity": "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@mjackson/node-fetch-server": "^0.7.0",
"es-module-lexer": "^1.7.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17",
"periscopic": "^4.0.2",
"turbo-stream": "^3.1.0",
"vitefu": "^1.1.1"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"vite": "*"
}
},
"node_modules/@vitejs/plugin-rsc/node_modules/@mjackson/node-fetch-server": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.7.0.tgz",
"integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
@@ -11332,18 +11299,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -14248,20 +14203,6 @@
"node": ">= 14.16"
}
},
"node_modules/periscopic": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-4.0.2.tgz",
"integrity": "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/estree": "*",
"is-reference": "^3.0.2",
"zimmerframe": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -14857,15 +14798,6 @@
"node": ">=0.10.0"
}
},
"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==",
"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"
}
},
"node_modules/react-router": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.1.tgz",
@@ -16878,15 +16810,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-3.1.0.tgz",
"integrity": "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -17500,28 +17423,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
@@ -18121,15 +18022,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",

View File

@@ -47,7 +47,6 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^3.0.6",
"react-router": "^7.9.1",
"react-syntax-highlighter": "^15.6.6",
"remark-breaks": "^4.0.0",

View File

@@ -1,3 +1,4 @@
import { cn } from "#/utils/utils";
import { ChatInterface } from "../../chat/chat-interface";
interface ChatInterfaceWrapperProps {
@@ -7,15 +8,16 @@ interface ChatInterfaceWrapperProps {
export function ChatInterfaceWrapper({
isRightPanelShown,
}: ChatInterfaceWrapperProps) {
if (!isRightPanelShown) {
return (
<div className="flex justify-center w-full h-full">
<div className="w-full max-w-[768px]">
<ChatInterface />
</div>
return (
<div className="flex justify-center w-full h-full">
<div
className={cn(
"w-full transition-all duration-300 ease-in-out",
isRightPanelShown ? "max-w-4xl" : "max-w-6xl",
)}
>
<ChatInterface />
</div>
);
}
return <ChatInterface />;
</div>
);
}

View File

@@ -1,35 +1,64 @@
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { cn } from "#/utils/utils";
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
import { ResizeHandle } from "../../../ui/resize-handle";
import { useResizablePanels } from "#/hooks/use-resizable-panels";
interface DesktopLayoutProps {
isRightPanelShown: boolean;
}
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
useResizablePanels({
defaultLeftWidth: 50,
minLeftWidth: 30,
maxLeftWidth: 80,
storageKey: "desktop-layout-panel-width",
});
return (
<PanelGroup
direction="horizontal"
className="grow h-full min-h-0 min-w-0"
autoSaveId="react-resizable-panels:layout"
>
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</Panel>
{isRightPanelShown && (
<>
<PanelResizeHandle className="cursor-ew-resize" />
<Panel
minSize={20}
maxSize={70}
className="flex flex-col overflow-hidden"
>
<div className="flex flex-col flex-1 gap-3">
<ConversationTabContent />
</div>
</Panel>
</>
)}
</PanelGroup>
<div className="h-full flex flex-col overflow-hidden">
<div
ref={containerRef}
className="flex flex-1 transition-all duration-300 ease-in-out overflow-hidden"
style={{
// Only apply smooth transitions when not dragging
transitionProperty: isDragging ? "none" : "all",
}}
>
{/* Left Panel (Chat) */}
<div
className="flex flex-col bg-base overflow-hidden transition-all duration-300 ease-in-out"
style={{
width: isRightPanelShown ? `${leftWidth}%` : "100%",
transitionProperty: isDragging ? "none" : "all",
}}
>
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</div>
{/* Resize Handle */}
{isRightPanelShown && <ResizeHandle onMouseDown={handleMouseDown} />}
{/* Right Panel */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isRightPanelShown
? "translate-x-0 opacity-100"
: "w-0 translate-x-full opacity-0",
)}
style={{
width: isRightPanelShown ? `${rightWidth}%` : "0%",
transitionProperty: isDragging ? "opacity, transform" : "all",
}}
>
<div className="flex flex-col flex-1 gap-3 min-w-max h-full">
<ConversationTabContent />
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,20 +8,30 @@ interface MobileLayoutProps {
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
return (
<div className="flex flex-col gap-3 overflow-auto w-full">
<div className="relative h-full flex flex-col overflow-hidden">
{/* Chat area - shrinks when panel slides up */}
<div
className={cn(
"overflow-hidden w-full bg-base min-h-[600px]",
!isRightPanelShown && "h-full",
"flex-1 bg-base overflow-hidden transition-all duration-300 ease-in-out",
isRightPanelShown ? "flex-[0.6]" : "flex-1",
)}
>
<ChatInterface />
</div>
{isRightPanelShown && (
<div className="h-full w-full min-h-[494px] flex flex-col gap-3">
{/* Bottom panel - slides up from bottom */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 transition-all duration-300 ease-in-out overflow-hidden",
isRightPanelShown
? "h-[40%] translate-y-0 opacity-100"
: "h-0 translate-y-full opacity-0",
)}
>
<div className="h-full flex flex-col gap-3 pt-2">
<ConversationTabContent />
</div>
)}
</div>
</div>
);
}

View File

@@ -23,7 +23,6 @@ export function ConversationTabs() {
isRightPanelShown,
setHasRightPanelToggled,
setSelectedTab,
setIsRightPanelShown,
} = useConversationStore();
// Persist selectedTab and isRightPanelShown in localStorage
@@ -46,11 +45,9 @@ export function ConversationTabs() {
useEffect(() => {
// Initialize selectedTab from localStorage if available
setSelectedTab(persistedSelectedTab);
setIsRightPanelShown(persistedIsRightPanelShown);
setHasRightPanelToggled(persistedIsRightPanelShown);
}, [
setSelectedTab,
setIsRightPanelShown,
setHasRightPanelToggled,
persistedSelectedTab,
persistedIsRightPanelShown,

View File

@@ -0,0 +1,21 @@
import { cn } from "#/utils/utils";
interface ResizeHandleProps {
onMouseDown: (e: React.MouseEvent) => void;
className?: string;
}
export function ResizeHandle({ onMouseDown, className }: ResizeHandleProps) {
return (
<div
className={cn("relative w-1 bg-transparent cursor-ew-resize", className)}
onMouseDown={onMouseDown}
>
{/* Visual indicator */}
<div className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2" />
{/* Larger hit area for easier dragging */}
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
interface UseResizablePanelsOptions {
defaultLeftWidth?: number;
minLeftWidth?: number;
maxLeftWidth?: number;
storageKey?: string;
}
export function useResizablePanels({
defaultLeftWidth = 50,
minLeftWidth = 30,
maxLeftWidth = 80,
storageKey = "desktop-layout-panel-width",
}: UseResizablePanelsOptions = {}) {
const [persistedWidth, setPersistedWidth] = useLocalStorage<number>(
storageKey,
defaultLeftWidth,
);
const [leftWidth, setLeftWidth] = useState(persistedWidth);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const clampWidth = useCallback(
(width: number) => Math.max(minLeftWidth, Math.min(maxLeftWidth, width)),
[minLeftWidth, maxLeftWidth],
);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - containerRect.left;
const containerWidth = containerRect.width;
const newLeftWidth = (mouseX / containerWidth) * 100;
const clampedWidth = clampWidth(newLeftWidth);
setLeftWidth(clampedWidth);
},
[isDragging, clampWidth],
);
const handleMouseUp = useCallback(() => {
if (isDragging) {
setIsDragging(false);
setPersistedWidth(leftWidth);
}
}, [isDragging, leftWidth, setPersistedWidth]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
}
return () => {
if (isDragging) {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
};
}, [isDragging, handleMouseMove, handleMouseUp]);
const rightWidth = 100 - leftWidth;
return {
leftWidth,
rightWidth,
isDragging,
containerRef,
handleMouseDown,
};
}

View File

@@ -111,9 +111,7 @@ function AppContent() {
<ConversationTabs />
</div>
<div className="flex h-full overflow-auto">
<ConversationMain />
</div>
<ConversationMain />
</div>
</EventHandler>
</ConversationSubscriptionsProvider>

View File

@@ -53,11 +53,17 @@ interface ConversationActions {
type ConversationStore = ConversationState & ConversationActions;
// Helper function to get initial right panel state from localStorage
const getInitialRightPanelState = (): boolean => {
const stored = localStorage.getItem("conversation-right-panel-shown");
return stored !== null ? JSON.parse(stored) : true;
};
export const useConversationStore = create<ConversationStore>()(
devtools(
(set) => ({
// Initial state
isRightPanelShown: true,
isRightPanelShown: getInitialRightPanelState(),
selectedTab: "editor" as ConversationTab,
images: [],
files: [],