-
{children}
+
+
+
+
+
+
+
+ <>
+ {children}
+
+ >
+
-
diff --git a/packages/epics/src/common/ai-left-panel-layout.tsx b/packages/epics/src/common/ai-left-panel-layout.tsx
new file mode 100644
index 000000000..172651440
--- /dev/null
+++ b/packages/epics/src/common/ai-left-panel-layout.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useCallback, useRef, useState } from 'react';
+import { Bot, ChevronsLeftRight, PanelLeftOpen } from 'lucide-react';
+
+import { AiLeftPanel } from './ai-left-panel';
+import { useIsMobile } from '../hooks';
+import { Drawer, DrawerContent } from '@hypha-platform/ui';
+import { cn } from '@hypha-platform/ui-utils';
+
+const MIN_WIDTH = 240;
+const MAX_WIDTH = 600;
+const DEFAULT_WIDTH = 320;
+
+type AiLeftPanelLayoutProps = {
+ children: React.ReactNode;
+};
+
+export function AiLeftPanelLayout({ children }: AiLeftPanelLayoutProps) {
+ const isMobile = useIsMobile();
+ const [panelOpen, setPanelOpen] = useState(true);
+ const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH);
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartX = useRef(0);
+ const dragStartWidth = useRef(DEFAULT_WIDTH);
+
+ const onResizeMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ dragStartX.current = e.clientX;
+ dragStartWidth.current = panelWidth;
+ setIsDragging(true);
+
+ const onMouseMove = (e: MouseEvent) => {
+ const delta = e.clientX - dragStartX.current;
+ const newWidth = Math.min(
+ MAX_WIDTH,
+ Math.max(MIN_WIDTH, dragStartWidth.current + delta),
+ );
+ setPanelWidth(newWidth);
+ };
+
+ const onMouseUp = () => {
+ setIsDragging(false);
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ },
+ [panelWidth],
+ );
+
+ return (
+
+ {!isMobile && (
+ <>
+
+
setPanelOpen(false)} />
+
+ {panelOpen && (
+
+ )}
+
+ >
+ )}
+
+ {!panelOpen && (
+
+ )}
+
+ {isMobile && (
+
+
+
+
setPanelOpen(false)} />
+
+
+
+ )}
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/epics/src/common/ai-left-panel.tsx b/packages/epics/src/common/ai-left-panel.tsx
new file mode 100644
index 000000000..682a0dff4
--- /dev/null
+++ b/packages/epics/src/common/ai-left-panel.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { PanelLeftClose } from 'lucide-react';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+type AiLeftPanelProps = {
+ onClose: () => void;
+ className?: string;
+};
+
+export function AiLeftPanel({ onClose, className }: AiLeftPanelProps) {
+ return (
+
+ );
+}
diff --git a/packages/epics/src/common/index.ts b/packages/epics/src/common/index.ts
index 6bef11a73..e526b702e 100644
--- a/packages/epics/src/common/index.ts
+++ b/packages/epics/src/common/index.ts
@@ -1,3 +1,5 @@
+export * from './ai-left-panel';
+export * from './ai-left-panel-layout';
export * from './authenticated-link-button';
export * from './button-back';
export * from './button-close';
diff --git a/packages/epics/src/hooks/index.ts b/packages/epics/src/hooks/index.ts
index 198846493..983d9310a 100644
--- a/packages/epics/src/hooks/index.ts
+++ b/packages/epics/src/hooks/index.ts
@@ -1,4 +1,5 @@
export * from './use-db-tokens';
+export * from './use-is-mobile';
export * from './use-scroll-to-errors';
export * from './use-db-spaces';
export * from './use-resubmit-proposal-data';
diff --git a/packages/epics/src/hooks/use-is-mobile.tsx b/packages/epics/src/hooks/use-is-mobile.tsx
new file mode 100644
index 000000000..38abc9b76
--- /dev/null
+++ b/packages/epics/src/hooks/use-is-mobile.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import * as React from 'react';
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile(): boolean {
+ const [isMobile, setIsMobile] = React.useState
(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener('change', onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener('change', onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index b7c3fce71..b23416136 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -16,8 +16,9 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.2",
- "@radix-ui/react-slot": "^1.2.3"
- },
+"@radix-ui/react-slot": "^1.2.3",
+ "vaul": "^0.9.9"
+},
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts"
diff --git a/packages/ui/src/drawer.tsx b/packages/ui/src/drawer.tsx
new file mode 100644
index 000000000..ac0c55c59
--- /dev/null
+++ b/packages/ui/src/drawer.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import * as React from 'react';
+import { Drawer as DrawerPrimitive } from 'vaul';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+const Drawer = ({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps) => (
+
+);
+Drawer.displayName = 'Drawer';
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+const DrawerOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+const DrawerContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+));
+DrawerContent.displayName = 'DrawerContent';
+
+const DrawerHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerHeader.displayName = 'DrawerHeader';
+
+const DrawerFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerFooter.displayName = 'DrawerFooter';
+
+const DrawerTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+const DrawerDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index b5bf99f20..305182cff 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -21,6 +21,7 @@ export * from './chips';
export * from './combobox';
export * from './container';
export * from './date-picker';
+export * from './drawer';
export * from './disposable-label';
export * from './dropdown-menu';
export * from './error-alert';
diff --git a/packages/ui/src/organisms/footer.tsx b/packages/ui/src/organisms/footer.tsx
index a3405a444..90aab2600 100644
--- a/packages/ui/src/organisms/footer.tsx
+++ b/packages/ui/src/organisms/footer.tsx
@@ -17,7 +17,7 @@ const customLabelStyles: React.CSSProperties = {
export const Footer = () => {
return (
-
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 090762a01..f793b0416 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1064,6 +1064,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.2)(react@19.1.2)
+ vaul:
+ specifier: ^0.9.9
+ version: 0.9.9(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
devDependencies:
'@hypha-platform/config-eslint':
specifier: workspace:*
@@ -19143,6 +19146,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vaul@0.9.9:
+ resolution: {integrity: sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -44349,6 +44358,15 @@ snapshots:
vary@1.1.2: {}
+ vaul@0.9.9(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
+ dependencies:
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ react: 19.1.2
+ react-dom: 19.1.2(react@19.1.2)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3