Эх сурвалжийг харах

Add resizable and collapsible panel layout (#5926)

Co-authored-by: openhands <openhands@all-hands.dev>
tofarr 1 жил өмнө
parent
commit
c37e865c56

+ 191 - 0
frontend/src/components/layout/resizable-panel.tsx

@@ -0,0 +1,191 @@
+import React, { CSSProperties, JSX, useEffect, useRef, useState } from "react";
+import {
+  VscChevronDown,
+  VscChevronLeft,
+  VscChevronRight,
+  VscChevronUp,
+} from "react-icons/vsc";
+import { twMerge } from "tailwind-merge";
+import { IconButton } from "../shared/buttons/icon-button";
+
+export enum Orientation {
+  HORIZONTAL = "horizontal",
+  VERTICAL = "vertical",
+}
+
+enum Collapse {
+  COLLAPSED = "collapsed",
+  SPLIT = "split",
+  FILLED = "filled",
+}
+
+type ResizablePanelProps = {
+  firstChild: React.ReactNode;
+  firstClassName: string | undefined;
+  secondChild: React.ReactNode;
+  secondClassName: string | undefined;
+  className: string | undefined;
+  orientation: Orientation;
+  initialSize: number;
+};
+
+export function ResizablePanel({
+  firstChild,
+  firstClassName,
+  secondChild,
+  secondClassName,
+  className,
+  orientation,
+  initialSize,
+}: ResizablePanelProps): JSX.Element {
+  const [firstSize, setFirstSize] = useState<number>(initialSize);
+  const [dividerPosition, setDividerPosition] = useState<number | null>(null);
+  const firstRef = useRef<HTMLDivElement>(null);
+  const secondRef = useRef<HTMLDivElement>(null);
+  const [collapse, setCollapse] = useState<Collapse>(Collapse.SPLIT);
+  const isHorizontal = orientation === Orientation.HORIZONTAL;
+
+  useEffect(() => {
+    if (dividerPosition == null || !firstRef.current) {
+      return undefined;
+    }
+    const getFirstSizeFromEvent = (e: MouseEvent) => {
+      const position = isHorizontal ? e.clientX : e.clientY;
+      return firstSize + position - dividerPosition;
+    };
+    const onMouseMove = (e: MouseEvent) => {
+      e.preventDefault();
+      const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
+      const { current } = firstRef;
+      if (current) {
+        if (isHorizontal) {
+          current.style.width = newFirstSize;
+          current.style.minWidth = newFirstSize;
+        } else {
+          current.style.height = newFirstSize;
+          current.style.minHeight = newFirstSize;
+        }
+      }
+    };
+    const onMouseUp = (e: MouseEvent) => {
+      e.preventDefault();
+      if (firstRef.current) {
+        firstRef.current.style.transition = "";
+      }
+      if (secondRef.current) {
+        secondRef.current.style.transition = "";
+      }
+      setFirstSize(getFirstSizeFromEvent(e));
+      setDividerPosition(null);
+      document.removeEventListener("mousemove", onMouseMove);
+      document.removeEventListener("mouseup", onMouseUp);
+    };
+    document.addEventListener("mousemove", onMouseMove);
+    document.addEventListener("mouseup", onMouseUp);
+    return () => {
+      document.removeEventListener("mousemove", onMouseMove);
+      document.removeEventListener("mouseup", onMouseUp);
+    };
+  }, [dividerPosition, firstSize, orientation]);
+
+  const onMouseDown = (e: React.MouseEvent) => {
+    e.preventDefault();
+    if (firstRef.current) {
+      firstRef.current.style.transition = "none";
+    }
+    if (secondRef.current) {
+      secondRef.current.style.transition = "none";
+    }
+    const position = isHorizontal ? e.clientX : e.clientY;
+    setDividerPosition(position);
+  };
+
+  const getStyleForFirst = () => {
+    const style: CSSProperties = { overflow: "hidden" };
+    if (collapse === Collapse.COLLAPSED) {
+      style.opacity = 0;
+      style.width = 0;
+      style.minWidth = 0;
+      style.height = 0;
+      style.minHeight = 0;
+    } else if (collapse === Collapse.SPLIT) {
+      const firstSizePx = `${firstSize}px`;
+      if (isHorizontal) {
+        style.width = firstSizePx;
+        style.minWidth = firstSizePx;
+      } else {
+        style.height = firstSizePx;
+        style.minHeight = firstSizePx;
+      }
+    } else {
+      style.flexGrow = 1;
+    }
+    return style;
+  };
+
+  const getStyleForSecond = () => {
+    const style: CSSProperties = { overflow: "hidden" };
+    if (collapse === Collapse.FILLED) {
+      style.opacity = 0;
+      style.width = 0;
+      style.minWidth = 0;
+      style.height = 0;
+      style.minHeight = 0;
+    } else if (collapse === Collapse.SPLIT) {
+      style.flexGrow = 1;
+    } else {
+      style.flexGrow = 1;
+    }
+    return style;
+  };
+
+  const onCollapse = () => {
+    if (collapse === Collapse.SPLIT) {
+      setCollapse(Collapse.COLLAPSED);
+    } else {
+      setCollapse(Collapse.SPLIT);
+    }
+  };
+
+  const onExpand = () => {
+    if (collapse === Collapse.SPLIT) {
+      setCollapse(Collapse.FILLED);
+    } else {
+      setCollapse(Collapse.SPLIT);
+    }
+  };
+
+  return (
+    <div className={twMerge("flex", !isHorizontal && "flex-col", className)}>
+      <div
+        ref={firstRef}
+        className={twMerge(firstClassName, "transition-all ease-soft-spring")}
+        style={getStyleForFirst()}
+      >
+        {firstChild}
+      </div>
+      <div
+        className={`${isHorizontal ? "cursor-ew-resize w-3 flex-col" : "cursor-ns-resize h-3 flex-row"} shrink-0 flex justify-center items-center`}
+        onMouseDown={collapse === Collapse.SPLIT ? onMouseDown : undefined}
+      >
+        <IconButton
+          icon={isHorizontal ? <VscChevronLeft /> : <VscChevronUp />}
+          ariaLabel="Collapse"
+          onClick={onCollapse}
+        />
+        <IconButton
+          icon={isHorizontal ? <VscChevronRight /> : <VscChevronDown />}
+          ariaLabel="Expand"
+          onClick={onExpand}
+        />
+      </div>
+      <div
+        ref={secondRef}
+        className={twMerge(secondClassName, "transition-all ease-soft-spring")}
+        style={getStyleForSecond()}
+      >
+        {secondChild}
+      </div>
+    </div>
+  );
+}

+ 58 - 16
frontend/src/routes/_oh.app/route.tsx

@@ -25,6 +25,10 @@ import { useAuth } from "#/context/auth-context";
 import { useSettings } from "#/context/settings-context";
 import { useConversationConfig } from "#/hooks/query/use-conversation-config";
 import { Container } from "#/components/layout/container";
+import {
+  Orientation,
+  ResizablePanel,
+} from "#/components/layout/resizable-panel";
 import Security from "#/components/shared/modals/security/security";
 import { useEndSession } from "#/hooks/use-end-session";
 import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
@@ -35,6 +39,7 @@ import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
 function AppContent() {
   const { gitHubToken } = useAuth();
   const endSession = useEndSession();
+  const [width, setWidth] = React.useState(window.innerWidth);
 
   const { settings } = useSettings();
   const { conversationId } = useConversation();
@@ -87,24 +92,49 @@ function AppContent() {
     dispatch(clearJupyter());
   });
 
+  function handleResize() {
+    setWidth(window.innerWidth);
+  }
+
+  React.useEffect(() => {
+    window.addEventListener("resize", handleResize);
+    return () => {
+      window.removeEventListener("resize", handleResize);
+    };
+  }, []);
+
   const {
     isOpen: securityModalIsOpen,
     onOpen: onSecurityModalOpen,
     onOpenChange: onSecurityModalOpenChange,
   } = useDisclosure();
 
-  return (
-    <WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
-      <EventHandler>
-        <div data-testid="app-route" className="flex flex-col h-full gap-3">
-          <div className="flex h-full overflow-auto gap-3">
-            <Container className="w-full md:w-[390px] max-h-full relative">
-              <ChatInterface />
-            </Container>
-
-            <div className="hidden md:flex flex-col grow gap-3">
+  function renderMain() {
+    if (width <= 640) {
+      return (
+        <div className="rounded-xl overflow-hidden border border-neutral-600 w-full">
+          <ChatInterface />
+        </div>
+      );
+    }
+    return (
+      <ResizablePanel
+        orientation={Orientation.HORIZONTAL}
+        className="grow h-full min-h-0 min-w-0"
+        initialSize={500}
+        firstClassName="rounded-xl overflow-hidden border border-neutral-600"
+        secondClassName="flex flex-col overflow-hidden"
+        firstChild={<ChatInterface />}
+        secondChild={
+          <ResizablePanel
+            orientation={Orientation.VERTICAL}
+            className="grow h-full min-h-0 min-w-0"
+            initialSize={500}
+            firstClassName="rounded-xl overflow-hidden border border-neutral-600"
+            secondClassName="flex flex-col overflow-hidden"
+            firstChild={
               <Container
-                className="h-2/3"
+                className="h-full"
                 labels={[
                   { label: "Workspace", to: "", icon: <CodeIcon /> },
                   { label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
@@ -124,18 +154,30 @@ function AppContent() {
                   <Outlet />
                 </FilesProvider>
               </Container>
-              {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
-               * that it loads only in the client-side. */}
+            }
+            secondChild={
               <Container
-                className="h-1/3 overflow-scroll"
+                className="h-full overflow-scroll"
                 label={<TerminalStatusLabel />}
               >
+                {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
+                 * that it loads only in the client-side. */}
                 <React.Suspense fallback={<div className="h-full" />}>
                   <Terminal secrets={secrets} />
                 </React.Suspense>
               </Container>
-            </div>
-          </div>
+            }
+          />
+        }
+      />
+    );
+  }
+
+  return (
+    <WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
+      <EventHandler>
+        <div data-testid="app-route" className="flex flex-col h-full gap-3">
+          <div className="flex h-full overflow-auto">{renderMain()}</div>
 
           <div className="h-[60px]">
             <Controls