feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)

- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑
- 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择
- 新增 IDE 组件,整合文件浏览器和代码编辑器
- 新增 SessionPanel 组件,用于会话管理
- 添加文件写入 API(PUT /api/files/write)
- 优化布局:IDE 始终显示,移除文件切换按钮
- 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
2025-12-17 16:55:22 +08:00
parent ddec356117
commit 250d2cb4b5
11 changed files with 1376 additions and 113 deletions
+286
View File
@@ -0,0 +1,286 @@
/**
* CodeEditor Component
*
* 基于 CodeMirror 的代码编辑器,支持多标签页和保存功能
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { X, Save, Circle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn.js';
import { writeFile } from '../api/client.js';
import { useTheme } from '../hooks/useTheme.js';
export interface EditorTab {
id: string;
path: string;
name: string;
content: string;
originalContent: string;
language: string;
}
interface CodeEditorProps {
tabs: EditorTab[];
activeTabId: string | null;
onTabChange: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onContentChange: (tabId: string, content: string) => void;
onSave?: (tabId: string, path: string, content: string) => void;
className?: string;
}
// 根据文件扩展名获取语言
function getLanguageExtension(language: string) {
switch (language) {
case 'javascript':
case 'typescript':
case 'jsx':
case 'tsx':
return javascript({ jsx: true, typescript: language.includes('typescript') || language === 'tsx' });
case 'python':
return python();
case 'json':
return json();
case 'html':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'markdown':
case 'md':
return markdown();
default:
return [];
}
}
// 根据文件名获取语言类型
export function getLanguageFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const languageMap: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
py: 'python',
json: 'json',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
less: 'less',
md: 'markdown',
markdown: 'markdown',
};
return languageMap[ext] || 'text';
}
export function CodeEditor({
tabs,
activeTabId,
onTabChange,
onTabClose,
onContentChange,
onSave,
className,
}: CodeEditorProps) {
const { resolvedTheme } = useTheme();
const [saving, setSaving] = useState<string | null>(null);
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId),
[tabs, activeTabId]
);
// 检查标签是否有未保存的更改
const hasUnsavedChanges = useCallback(
(tab: EditorTab) => tab.content !== tab.originalContent,
[]
);
// 保存文件
const handleSave = useCallback(
async (tab: EditorTab) => {
if (!hasUnsavedChanges(tab)) return;
setSaving(tab.id);
try {
const result = await writeFile(tab.path, tab.content);
if (result.success) {
onSave?.(tab.id, tab.path, tab.content);
toast.success(`Saved ${tab.name}`);
} else {
toast.error('Failed to save file');
}
} catch (error) {
console.error('Failed to save file:', error);
toast.error('Failed to save file');
} finally {
setSaving(null);
}
},
[hasUnsavedChanges, onSave]
);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (activeTab && hasUnsavedChanges(activeTab)) {
handleSave(activeTab);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTab, hasUnsavedChanges, handleSave]);
// 编辑器扩展
const extensions = useMemo(() => {
if (!activeTab) return [];
return [getLanguageExtension(activeTab.language)].flat();
}, [activeTab]);
if (tabs.length === 0) {
return (
<div className={cn('flex items-center justify-center h-full bg-surface-base text-fg-muted', className)}>
<p>No files open</p>
</div>
);
}
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Tab Bar */}
<div className="flex items-center border-b border-line bg-surface-subtle overflow-x-auto">
<AnimatePresence mode="popLayout">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isModified = hasUnsavedChanges(tab);
const isSaving = saving === tab.id;
return (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className={cn(
'flex items-center gap-1.5 px-3 py-2 border-r border-line cursor-pointer group min-w-0',
'hover:bg-surface-muted transition-colors',
isActive && 'bg-surface-base border-b-2 border-b-primary-500'
)}
onClick={() => onTabChange(tab.id)}
>
{/* 修改指示器 */}
{isModified && !isSaving && (
<Circle size={8} className="fill-orange-400 text-orange-400 flex-shrink-0" />
)}
{isSaving && (
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
)}
{/* 文件名 */}
<span
className={cn(
'text-sm truncate max-w-[150px]',
isActive ? 'text-fg' : 'text-fg-muted'
)}
title={tab.path}
>
{tab.name}
</span>
{/* 关闭按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded hover:bg-surface-emphasis transition-colors flex-shrink-0',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-100'
)}
>
<X size={14} className="text-fg-muted" />
</button>
</motion.div>
);
})}
</AnimatePresence>
{/* 保存按钮 */}
{activeTab && hasUnsavedChanges(activeTab) && (
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleSave(activeTab)}
disabled={saving === activeTab.id}
className="ml-auto mr-2 p-1.5 rounded hover:bg-surface-muted transition-colors"
title="Save (Cmd+S)"
>
<Save size={16} className="text-fg-muted" />
</motion.button>
)}
</div>
{/* Editor */}
<div className="flex-1 overflow-hidden">
{activeTab && (
<CodeMirror
value={activeTab.content}
height="100%"
theme={resolvedTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={(value) => onContentChange(activeTab.id, value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
className="h-full text-sm"
/>
)}
</div>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
/**
* FileExplorer Component
*
* 文件树组件,支持展开/折叠和文件选择
*/
import { useState, useEffect, useCallback } from 'react';
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../utils/cn.js';
import { getFileTree, type FileTreeNode } from '../api/client.js';
interface FileExplorerProps {
onFileSelect?: (path: string, name: string) => void;
className?: string;
/** 工作目录路径,显示在标题栏 */
workingDirectory?: string;
}
// 文件图标颜色映射
const fileIconColors: Record<string, string> = {
ts: 'text-blue-400',
tsx: 'text-blue-400',
js: 'text-yellow-300',
jsx: 'text-yellow-300',
json: 'text-yellow-500',
md: 'text-fg-muted',
css: 'text-pink-400',
scss: 'text-pink-400',
html: 'text-orange-400',
py: 'text-green-400',
go: 'text-cyan-400',
rs: 'text-orange-500',
java: 'text-red-400',
c: 'text-blue-500',
cpp: 'text-blue-500',
h: 'text-purple-400',
};
function getFileIconColor(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
return fileIconColors[ext] || 'text-fg-muted';
}
interface TreeNodeProps {
node: FileTreeNode;
depth: number;
onFileSelect?: (path: string, name: string) => void;
expandedPaths: Set<string>;
onToggleExpand: (path: string) => void;
}
function TreeNode({ node, depth, onFileSelect, expandedPaths, onToggleExpand }: TreeNodeProps) {
const isExpanded = expandedPaths.has(node.path);
const isDirectory = node.type === 'directory';
const hasChildren = isDirectory && node.children && node.children.length > 0;
const handleClick = () => {
if (isDirectory) {
onToggleExpand(node.path);
} else {
onFileSelect?.(node.path, node.name);
}
};
return (
<div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
'flex items-center gap-1 py-1 px-2 cursor-pointer rounded',
'hover:bg-surface-muted transition-colors',
'text-sm'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
>
{/* 展开/折叠图标 */}
{isDirectory ? (
<span className="w-4 h-4 flex items-center justify-center text-fg-muted">
{hasChildren ? (
isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)
) : null}
</span>
) : (
<span className="w-4" />
)}
{/* 文件/文件夹图标 */}
{isDirectory ? (
isExpanded ? (
<FolderOpen size={16} className="text-yellow-400 flex-shrink-0" />
) : (
<Folder size={16} className="text-yellow-400 flex-shrink-0" />
)
) : (
<File size={16} className={cn('flex-shrink-0', getFileIconColor(node.name))} />
)}
{/* 文件名 */}
<span className="truncate text-fg">{node.name}</span>
</motion.div>
{/* 子节点 */}
<AnimatePresence>
{isDirectory && isExpanded && node.children && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
>
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
onFileSelect={onFileSelect}
expandedPaths={expandedPaths}
onToggleExpand={onToggleExpand}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
// 加载文件树
const loadTree = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getFileTree('.', 4);
if (result.success) {
setTree(result.data.tree);
// 默认展开第一层
const firstLevel = new Set(
result.data.tree
.filter((n) => n.type === 'directory')
.map((n) => n.path)
);
setExpandedPaths(firstLevel);
} else {
setError('Failed to load file tree');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file tree');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTree();
}, [loadTree]);
const handleToggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
if (loading) {
return (
<div className={cn('flex items-center justify-center h-full', className)}>
<div className="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error) {
return (
<div className={cn('flex flex-col items-center justify-center h-full gap-3 p-4', className)}>
<p className="text-red-400 text-sm">{error}</p>
<button
onClick={loadTree}
className="flex items-center gap-2 px-3 py-1.5 bg-surface-muted hover:bg-surface-emphasis rounded text-sm text-fg-secondary transition-colors"
>
<RefreshCw size={14} />
Retry
</button>
</div>
);
}
return (
<div className={cn('flex flex-col h-full bg-surface-subtle', className)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-line gap-2">
<div className="flex items-center gap-1.5 min-w-0 flex-1 group relative">
<Folder size={14} className="flex-shrink-0 text-fg-muted" />
<span className="text-sm font-mono text-fg-muted truncate">
{workingDirectory || 'Files'}
</span>
{/* Tooltip - 悬浮时显示完整路径 */}
{workingDirectory && (
<div className="absolute left-0 top-full mt-1 z-50 hidden group-hover:block">
<div className="bg-surface-emphasis border border-line rounded-md shadow-lg px-3 py-2 max-w-[400px]">
<p className="text-xs font-mono text-fg break-all whitespace-pre-wrap">
{workingDirectory}
</p>
</div>
</div>
)}
</div>
<button
onClick={loadTree}
className="p-1 hover:bg-surface-muted rounded transition-colors flex-shrink-0"
title="Refresh"
>
<RefreshCw size={14} className="text-fg-muted" />
</button>
</div>
{/* Tree */}
<div className="flex-1 overflow-auto py-2">
{tree.length === 0 ? (
<p className="text-fg-muted text-sm text-center py-4">No files found</p>
) : (
tree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
onFileSelect={onFileSelect}
expandedPaths={expandedPaths}
onToggleExpand={handleToggleExpand}
/>
))
)}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
/**
* IDE Component
*
* 整合文件浏览器和代码编辑器的 IDE 组件
*/
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { cn } from '../utils/cn.js';
import { readFile, getWorkingDirectory } from '../api/client.js';
import { FileExplorer } from './FileExplorer.js';
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
interface IDEProps {
className?: string;
/** 文件浏览器宽度 */
sidebarWidth?: number;
}
export function IDE({ className, sidebarWidth = 256 }: IDEProps) {
const [tabs, setTabs] = useState<EditorTab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>('');
// 获取工作目录
useEffect(() => {
getWorkingDirectory()
.then((result) => {
if (result?.data?.workingDirectory) {
setWorkingDirectory(result.data.workingDirectory);
}
})
.catch(() => {
// 忽略错误
});
}, []);
// 打开文件
const handleFileSelect = useCallback(async (path: string, name: string) => {
// 检查是否已打开
const existingTab = tabs.find((tab) => tab.path === path);
if (existingTab) {
setActiveTabId(existingTab.id);
return;
}
// 读取文件内容
try {
const result = await readFile(path);
if (result.success && result.data.encoding === 'utf-8') {
const newTab: EditorTab = {
id: `tab-${Date.now()}`,
path,
name,
content: result.data.content,
originalContent: result.data.content,
language: getLanguageFromFilename(name),
};
setTabs((prev) => [...prev, newTab]);
setActiveTabId(newTab.id);
} else if (result.data.encoding === 'base64') {
toast.error('Cannot edit binary files');
} else {
toast.error('Failed to open file');
}
} catch (error) {
console.error('Failed to open file:', error);
toast.error('Failed to open file');
}
}, [tabs]);
// 切换标签
const handleTabChange = useCallback((tabId: string) => {
setActiveTabId(tabId);
}, []);
// 关闭标签
const handleTabClose = useCallback((tabId: string) => {
const tabIndex = tabs.findIndex((tab) => tab.id === tabId);
const tab = tabs[tabIndex];
// 检查是否有未保存的更改
if (tab && tab.content !== tab.originalContent) {
const confirmed = window.confirm(`${tab.name} has unsaved changes. Close anyway?`);
if (!confirmed) return;
}
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
// 如果关闭的是当前标签,切换到相邻标签
if (activeTabId === tabId) {
if (tabs.length > 1) {
const newIndex = tabIndex === 0 ? 1 : tabIndex - 1;
setActiveTabId(tabs[newIndex].id);
} else {
setActiveTabId(null);
}
}
}, [tabs, activeTabId]);
// 内容更改
const handleContentChange = useCallback((tabId: string, content: string) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId ? { ...tab, content } : tab
)
);
}, []);
// 保存后更新原始内容
const handleSave = useCallback((tabId: string, _path: string, content: string) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId ? { ...tab, originalContent: content } : tab
)
);
}, []);
return (
<div className={cn('flex h-full', className)}>
{/* 文件浏览器 */}
<div
className="flex-shrink-0 border-r border-line"
style={{ width: sidebarWidth }}
>
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
</div>
{/* 代码编辑器 */}
<div className="flex-1 min-w-0">
<CodeEditor
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
</div>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
/**
* SessionPanel Component
*
* 会话管理面板:列出、创建、切换、删除会话
*/
import { useState, useEffect, useCallback, forwardRef } from 'react';
import { X, Plus, MessageSquare, Trash2, MessageCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations';
import { Button } from '../primitives/Button';
import { SessionSkeleton } from './Skeleton';
import { listSessions, createSession, deleteSession, type Session } from '../api/client.js';
interface SessionPanelProps {
onClose: () => void;
/** 当前选中的会话 ID */
currentSessionId: string | null;
/** 选择会话回调 */
onSelectSession: (id: string) => void;
/** 创建会话回调 */
onCreateSession: (session: Session) => void;
/** 会话标题更新事件(从 WebSocket 接收) */
sessionTitleUpdate?: { sessionId: string; name: string } | null;
/** 是否启用响应式布局 */
responsive?: boolean;
}
export function SessionPanel({
onClose,
currentSessionId,
onSelectSession,
onCreateSession,
sessionTitleUpdate,
responsive = false,
}: SessionPanelProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
// 加载会话列表
const loadSessions = useCallback(async () => {
setIsLoading(true);
try {
const { data } = await listSessions();
setSessions(data);
} catch (error) {
console.error('Failed to load sessions:', error);
toast.error('Failed to load sessions');
} finally {
setIsLoading(false);
}
}, []);
// 创建新会话
const handleCreate = async () => {
try {
const { data } = await createSession();
setSessions((prev) => [data, ...prev]);
onCreateSession(data);
toast.success('Session created');
onClose();
} catch (error) {
console.error('Failed to create session:', error);
toast.error('Failed to create session');
}
};
// 删除会话
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
toast.success('Session deleted');
if (currentSessionId === id) {
const remaining = sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
onSelectSession(remaining[0].id);
}
}
} catch (error) {
console.error('Failed to delete session:', error);
toast.error('Failed to delete session');
}
};
// 选择会话
const handleSelectSession = (id: string) => {
onSelectSession(id);
onClose();
};
// 初始加载
useEffect(() => {
loadSessions();
}, [loadSessions]);
// 处理会话标题更新
useEffect(() => {
if (sessionTitleUpdate) {
setSessions((prev) =>
prev.map((s) =>
s.id === sessionTitleUpdate.sessionId ? { ...s, name: sessionTitleUpdate.name } : s
)
);
}
}, [sessionTitleUpdate]);
// 会话列表项
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => (
<motion.div
ref={ref}
layout
variants={fadeInUp}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={() => handleSelectSession(session.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
'hover:bg-surface-muted transition-colors',
'active:bg-surface-emphasis',
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
)}
>
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</div>
<div className="text-xs text-fg-subtle">{session.messageCount} messages</div>
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
aria-label="Delete session"
>
<Trash2 size={14} className="text-red-400" />
</motion.button>
</motion.div>
));
// 空状态
const EmptyState = () => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={smoothTransition}
className="p-8 text-center"
>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-muted/50 flex items-center justify-center">
<MessageCircle size={32} className="text-fg-subtle" />
</div>
<p className="text-fg-muted mb-4">No conversations yet</p>
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
Create your first chat
</Button>
</motion.div>
);
// 加载状态
const LoadingState = () => (
<div className="p-4 space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<SessionSkeleton key={i} />
))}
</div>
);
// 面板内容
const panelContent = (
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
className={cn(
'bg-surface-base border border-line rounded-xl shadow-2xl flex flex-col overflow-hidden',
responsive
? 'fixed inset-4 md:inset-auto md:fixed md:right-4 md:top-4 md:bottom-4 md:w-96'
: 'w-96 max-h-[80vh]'
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-line">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
{!isLoading && (
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
{sessions.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
New
</Button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
>
<X size={20} className="text-fg-muted" />
</motion.button>
</div>
</div>
{/* Session List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<LoadingState />
) : sessions.length === 0 ? (
<EmptyState />
) : (
<div className="p-2 space-y-1">
<AnimatePresence mode="popLayout">
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</AnimatePresence>
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-line text-center">
<span className="text-xs text-fg-subtle">
Click a session to switch, or create a new one
</span>
</div>
</motion.div>
);
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
{panelContent}
</motion.div>
</AnimatePresence>
);
}