mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(frontend): Animate conversation panels (#11099)
This commit is contained in:
108
frontend/package-lock.json
generated
108
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
frontend/src/components/ui/resize-handle.tsx
Normal file
21
frontend/src/components/ui/resize-handle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
frontend/src/hooks/use-resizable-panels.ts
Normal file
85
frontend/src/hooks/use-resizable-panels.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -111,9 +111,7 @@ function AppContent() {
|
||||
<ConversationTabs />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full overflow-auto">
|
||||
<ConversationMain />
|
||||
</div>
|
||||
<ConversationMain />
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user