-
{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..73f094de9
--- /dev/null
+++ b/packages/epics/src/common/ai-left-panel.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { useState } from 'react';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+import {
+ AiPanelHeader,
+ AiPanelMessages,
+ AiPanelChatBar,
+ MOCK_MODEL_OPTIONS,
+ MOCK_SUGGESTIONS,
+ MOCK_WELCOME_MESSAGE,
+} from './ai-panel';
+
+type AiLeftPanelProps = {
+ onClose: () => void;
+ className?: string;
+};
+
+export function AiLeftPanel({ onClose, className }: AiLeftPanelProps) {
+ const [input, setInput] = useState('');
+ const [selectedModel, setSelectedModel] = useState(MOCK_MODEL_OPTIONS[0]!);
+ const [messages, setMessages] = useState([MOCK_WELCOME_MESSAGE]);
+ const [showSuggestions, setShowSuggestions] = useState(true);
+ const [isStreaming, setIsStreaming] = useState(false);
+
+ const handleSend = () => {
+ // Placeholder - no business logic
+ };
+
+ const handleSuggestionSelect = (text: string) => {
+ setShowSuggestions(false);
+ setInput(text);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx b/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx
new file mode 100644
index 000000000..95a64e065
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import { useRef } from 'react';
+import {
+ Code2,
+ Image,
+ Mic,
+ Paperclip,
+ Search,
+ Send,
+ Square,
+} from 'lucide-react';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+type AiPanelChatBarProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onSend: () => void;
+ isStreaming?: boolean;
+ placeholder?: string;
+};
+
+export function AiPanelChatBar({
+ value,
+ onChange,
+ onSend,
+ isStreaming = false,
+ placeholder = 'Ask Hypha AI anything...',
+}: AiPanelChatBarProps) {
+ const textareaRef = useRef
(null);
+
+ const autoResize = () => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height =
+ Math.min(textareaRef.current.scrollHeight, 160) + 'px';
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ onSend();
+ }
+ };
+
+ const canSend = value.trim().length > 0 && !isStreaming;
+
+ return (
+
+
+ {/* Toolbar */}
+
+
+
+
+
+
+
+
+
+ {/* Textarea — full width */}
+
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/ai-panel-header.tsx b/packages/epics/src/common/ai-panel/ai-panel-header.tsx
new file mode 100644
index 000000000..ce559fc54
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/ai-panel-header.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown, PanelLeftClose, RefreshCw, Sparkles } from 'lucide-react';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+import type { ModelOption } from './mock-data';
+
+type AiPanelHeaderProps = {
+ onClose: () => void;
+ modelOptions: ModelOption[];
+ selectedModel: ModelOption;
+ onModelSelect: (model: ModelOption) => void;
+};
+
+export function AiPanelHeader({
+ onClose,
+ modelOptions,
+ selectedModel,
+ onModelSelect,
+}: AiPanelHeaderProps) {
+ const [showModelMenu, setShowModelMenu] = useState(false);
+ const menuRef = useRef(null);
+ const SelectedIcon = selectedModel.icon;
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ showModelMenu &&
+ menuRef.current &&
+ !menuRef.current.contains(e.target as Node)
+ ) {
+ setShowModelMenu(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showModelMenu]);
+
+ return (
+
+
+
+
+
+ {showModelMenu && (
+
+ {modelOptions.map((m) => {
+ const Icon = m.icon;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/ai-panel-message-bubble.tsx b/packages/epics/src/common/ai-panel/ai-panel-message-bubble.tsx
new file mode 100644
index 000000000..4e9a89e35
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/ai-panel-message-bubble.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { Bot, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+import type { Message } from './mock-data';
+
+type AiPanelMessageBubbleProps = {
+ message: Message;
+};
+
+export function AiPanelMessageBubble({ message }: AiPanelMessageBubbleProps) {
+ const isUser = message.role === 'user';
+
+ return (
+
+ {!isUser && (
+
+
+
+ )}
+
+
+ {message.content}
+ {message.isStreaming && (
+
+
+
+
+
+ )}
+
+ {!isUser && !message.isStreaming && (
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/ai-panel-messages.tsx b/packages/epics/src/common/ai-panel/ai-panel-messages.tsx
new file mode 100644
index 000000000..f9f536330
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/ai-panel-messages.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { useRef } from 'react';
+
+import { AiPanelMessageBubble } from './ai-panel-message-bubble';
+import { AiPanelSuggestions } from './ai-panel-suggestions';
+import type { Message } from './mock-data';
+
+type AiPanelMessagesProps = {
+ messages: Message[];
+ suggestions: readonly string[];
+ showSuggestions: boolean;
+ onSuggestionSelect: (text: string) => void;
+};
+
+export function AiPanelMessages({
+ messages,
+ suggestions,
+ showSuggestions,
+ onSuggestionSelect,
+}: AiPanelMessagesProps) {
+ const endRef = useRef(null);
+
+ return (
+
+
+ {messages.map((msg) => (
+
+ ))}
+
+ {showSuggestions && messages.length <= 1 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/ai-panel-suggestions.tsx b/packages/epics/src/common/ai-panel/ai-panel-suggestions.tsx
new file mode 100644
index 000000000..c112080c3
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/ai-panel-suggestions.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { cn } from '@hypha-platform/ui-utils';
+
+type AiPanelSuggestionsProps = {
+ suggestions: readonly string[];
+ onSelect: (suggestion: string) => void;
+};
+
+export function AiPanelSuggestions({
+ suggestions,
+ onSelect,
+}: AiPanelSuggestionsProps) {
+ return (
+
+ {suggestions.map((suggestion) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/epics/src/common/ai-panel/index.ts b/packages/epics/src/common/ai-panel/index.ts
new file mode 100644
index 000000000..f7e31c429
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/index.ts
@@ -0,0 +1,6 @@
+export { AiPanelHeader } from './ai-panel-header';
+export { AiPanelMessageBubble } from './ai-panel-message-bubble';
+export { AiPanelSuggestions } from './ai-panel-suggestions';
+export { AiPanelChatBar } from './ai-panel-chat-bar';
+export { AiPanelMessages } from './ai-panel-messages';
+export * from './mock-data';
diff --git a/packages/epics/src/common/ai-panel/mock-data.ts b/packages/epics/src/common/ai-panel/mock-data.ts
new file mode 100644
index 000000000..142edf7bc
--- /dev/null
+++ b/packages/epics/src/common/ai-panel/mock-data.ts
@@ -0,0 +1,43 @@
+import type { LucideIcon } from 'lucide-react';
+import { Zap, Brain, Globe } from 'lucide-react';
+
+export type ModelOption = {
+ id: string;
+ label: string;
+ icon: LucideIcon;
+};
+
+export const MOCK_MODEL_OPTIONS: ModelOption[] = [
+ { id: 'gemini-flash', label: 'Gemini 2.5 Flash', icon: Zap },
+ { id: 'gpt5', label: 'GPT-5', icon: Brain },
+ { id: 'gemini-pro', label: 'Gemini Pro', icon: Globe },
+];
+
+export const MOCK_SUGGESTIONS = [
+ 'Analyze recent signals',
+ 'Summarize top conversations',
+ 'Draft a coordination proposal',
+ 'Find alignment opportunities',
+];
+
+export type Message = {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ isStreaming?: boolean;
+};
+
+export const MOCK_WELCOME_MESSAGE: Message = {
+ id: 'welcome',
+ role: 'assistant',
+ content:
+ "Hello! I'm your Hypha AI assistant. I can help you analyze signals, draft proposals, understand community dynamics, and coordinate across spaces. What would you like to explore?",
+ timestamp: new Date(),
+};
+
+export const MOCK_AI_RESPONSES = [
+ 'Based on the current signal data, I can see a strong opportunity in the DeFi governance space. Three communities have shown 40%+ engagement spikes in the last 48 hours. Would you like me to generate a detailed breakdown?',
+ "I've analyzed 127 signals across your tracked spaces. The most actionable insight is a convergence of treasury proposals in 3 DAOs that align with your stated objectives. Shall I draft a summary report?",
+ 'The pattern suggests this is a coordination signal, not noise. Similar signals in Q3 preceded successful multi-DAO collaborations. I recommend prioritizing this for immediate conversation.',
+];
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