Skip to content

Commit

Permalink
Add resizable and collapsible panel layout (All-Hands-AI#5926)
Browse files Browse the repository at this point in the history
Co-authored-by: openhands <[email protected]>
  • Loading branch information
tofarr and openhands-agent authored Dec 30, 2024
1 parent 6523fca commit c37e865
Show file tree
Hide file tree
Showing 2 changed files with 249 additions and 16 deletions.
191 changes: 191 additions & 0 deletions frontend/src/components/layout/resizable-panel.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
74 changes: 58 additions & 16 deletions frontend/src/routes/_oh.app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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 /> },
Expand All @@ -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
Expand Down

0 comments on commit c37e865

Please sign in to comment.