Trae Assistant
fix: Remove unused FileText import in Sidebar.tsx
88d4036
import {
Plus,
Search,
History,
Folder,
CheckSquare,
MoreHorizontal,
LayoutGrid,
PanelLeftClose,
PanelLeftOpen,
Clock,
Edit2,
Trash2,
Share2,
Star,
ExternalLink,
FolderInput
} from 'lucide-react';
import { useState, useMemo, useRef, useEffect, memo } from 'react';
import type { ChatSession } from '../services/chatService';
import Modal from './Modal';
interface SidebarProps {
isCollapsed: boolean;
toggleSidebar: () => void;
onNavigate: (view: 'home' | 'detail' | 'original') => void;
activeView: 'home' | 'detail' | 'original';
onReset: () => void;
sessions: ChatSession[];
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onRenameSession: (id: string, newTitle: string) => void;
onDeleteSession: (id: string) => void;
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}分钟前`;
} else if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `${hours}小时前`;
} else {
return `${date.getMonth() + 1}${date.getDate()}日`;
}
};
export default memo(function Sidebar({
isCollapsed,
toggleSidebar,
activeView,
onNavigate,
onReset,
sessions,
currentSessionId,
onSelectSession,
onRenameSession,
onDeleteSession
}: SidebarProps) {
const [tooltip, setTooltip] = useState<{ text: string; top: number; visible: boolean }>({ text: '', top: 0, visible: false });
// Menu State
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const menuRef = useRef<HTMLDivElement>(null);
// Modal State
const [renameModal, setRenameModal] = useState<{ isOpen: boolean; sessionId: string; currentTitle: string }>({ isOpen: false, sessionId: '', currentTitle: '' });
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; sessionId: string }>({ isOpen: false, sessionId: '' });
const [newTitleInput, setNewTitleInput] = useState('');
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setActiveMenuId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleMenuClick = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
// Position menu to the right of the button if possible, or below
// Image shows it to the right or below. Let's align top-right of menu to bottom-right of button
setMenuPosition({ top: rect.bottom + 5, left: rect.right - 180 });
setActiveMenuId(sessionId);
};
const openRenameModal = (id: string, currentTitle: string) => {
setNewTitleInput(currentTitle);
setRenameModal({ isOpen: true, sessionId: id, currentTitle });
setActiveMenuId(null);
};
const openDeleteModal = (id: string) => {
setDeleteModal({ isOpen: true, sessionId: id });
setActiveMenuId(null);
};
const handleRenameConfirm = () => {
if (newTitleInput.trim()) {
onRenameSession(renameModal.sessionId, newTitleInput.trim());
}
setRenameModal({ isOpen: false, sessionId: '', currentTitle: '' });
};
const handleDeleteConfirm = () => {
onDeleteSession(deleteModal.sessionId);
setDeleteModal({ isOpen: false, sessionId: '' });
};
// Recent Activity Popover State
const [recentPopover, setRecentPopover] = useState<{ top: number; visible: boolean }>({ top: 0, visible: false });
const popoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showRecentPopover = (e: React.MouseEvent) => {
if (!isCollapsed) return;
if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current);
const rect = e.currentTarget.getBoundingClientRect();
setRecentPopover({
top: rect.top,
visible: true
});
};
const hideRecentPopover = () => {
popoverTimeoutRef.current = setTimeout(() => {
setRecentPopover(prev => ({ ...prev, visible: false }));
}, 300);
};
const keepRecentPopover = () => {
if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current);
};
useEffect(() => {
return () => {
if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current);
};
}, []);
const showTooltip = (e: React.MouseEvent, text: string) => {
if (!isCollapsed) return;
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({
text,
top: rect.top + rect.height / 2,
visible: true
});
};
const hideTooltip = () => {
setTooltip(prev => ({ ...prev, visible: false }));
};
const menuItems = useMemo(() => [
{ icon: Plus, label: '开启新对话', active: activeView === 'home', onClick: onReset },
{ icon: Search, label: '搜索', active: false },
{ icon: History, label: '历史', active: false },
{ icon: Folder, label: '项目', active: false },
{ icon: CheckSquare, label: '待办任务', active: false },
{ icon: LayoutGrid, label: '知识库', active: false },
// { icon: FileText, label: '原始样式', active: activeView === 'original', onClick: () => onNavigate('original') },
], [activeView, onReset, onNavigate]);
return (
<>
{/* Mobile Backdrop */}
{!isCollapsed && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 md:hidden"
onClick={toggleSidebar}
/>
)}
<div
className={`bg-[#F9FAFB] dark:bg-gray-900 text-gray-600 dark:text-gray-400 flex flex-col h-screen border-r border-gray-200 dark:border-gray-800 transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] ${
isCollapsed
? 'hidden md:flex w-[68px] relative z-20 flex-shrink-0'
: 'fixed inset-y-0 left-0 z-50 w-[260px] shadow-2xl md:relative md:shadow-none md:z-20 md:flex-shrink-0'
}`}
>
{/* Header */}
<div className="h-14 flex items-center relative pl-4 flex-shrink-0 mb-2">
<div
onClick={isCollapsed ? toggleSidebar : onReset}
className="group relative flex items-center !cursor-pointer overflow-hidden whitespace-nowrap transition-all duration-300 w-full select-none"
onMouseEnter={(e) => showTooltip(e, '打开侧栏')}
onMouseLeave={hideTooltip}
>
<div className="relative flex items-center justify-center w-9 h-9 flex-shrink-0">
<img
src="/vite.svg"
alt="Logo"
className={`w-7 h-7 rounded-full transition-all duration-300 !cursor-pointer ${isCollapsed ? 'group-hover:opacity-0 scale-100' : ''}`}
/>
{isCollapsed && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-0 group-hover:opacity-100 transition-all duration-300 scale-90 group-hover:scale-100">
<PanelLeftOpen size={20} className="text-gray-600 dark:text-gray-300" />
</div>
)}
</div>
<div className={`transition-all duration-300 overflow-hidden whitespace-nowrap flex flex-col justify-center ${
isCollapsed ? 'w-0 opacity-0 ml-0 translate-x-[-10px]' : 'ml-2 w-40 opacity-100 translate-x-0'
}`}>
<span className="font-semibold text-lg text-gray-900 dark:text-gray-100 leading-tight">Manus-clone-cn</span>
</div>
</div>
<button
onClick={toggleSidebar}
className={`!cursor-pointer absolute right-3 p-1.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-gray-800 rounded-md transition-all duration-200 active:scale-90 ${
isCollapsed ? 'opacity-0 pointer-events-none translate-x-2' : 'opacity-100 translate-x-0'
}`}
aria-label="Collapse sidebar"
>
<PanelLeftClose size={18} />
</button>
</div>
{/* Menu Items & Recent Activity Container */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Static Section: Menu Items + Divider */}
<div className="px-3 space-y-0.5 flex-shrink-0">
{menuItems.map((item, index) => (
<button
key={index}
onClick={item.onClick || (() => {})}
className={`!cursor-pointer w-full flex items-center h-[38px] px-3 rounded-lg transition-all duration-300 group relative select-none outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-opacity-50 ${
item.active
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm font-medium'
: 'hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 active:scale-[0.98]'
}`}
onMouseEnter={(e) => showTooltip(e, item.label)}
onMouseLeave={hideTooltip}
>
<item.icon size={18} className={`flex-shrink-0 transition-colors duration-200 ${item.active ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'}`} />
<div className={`overflow-hidden whitespace-nowrap transition-all duration-300 flex-1 text-left flex items-center ${
isCollapsed ? 'w-0 opacity-0 ml-0' : 'ml-3 w-full opacity-100'
}`}>
<span className="text-[14px]">{item.label}</span>
</div>
{!isCollapsed && item.label === '开启新对话'}
</button>
))}
{/* Divider */}
{/* <div className="py-4 px-2">
<div className="h-px bg-gray-200 dark:bg-gray-800 w-full"></div>
</div> */}
</div>
{/* Recent Activity Section - Scrollable */}
{!isCollapsed ? (
<div className="flex-1 flex flex-col min-h-0 px-3 overflow-hidden">
<div className="text-xs font-semibold text-gray-400 mb-2 mt-2 whitespace-nowrap transition-all duration-300 flex-shrink-0">
最近活动
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800 hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-700 -mx-1 px-1">
{sessions.map((session) => (
<div key={session.id} className="relative group">
<button
onClick={() => onSelectSession(session.id)}
className={`!cursor-pointer w-full flex items-center h-[38px] px-3 rounded-lg transition-all duration-300 relative select-none outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-opacity-50 mb-0.5 ${
currentSessionId === session.id && activeView === 'detail'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 active:scale-[0.98]'
}`}
onMouseEnter={(e) => showTooltip(e, session.title)}
onMouseLeave={hideTooltip}
>
<div className="flex-1 opacity-100 overflow-hidden whitespace-nowrap text-left flex items-center">
<div className="text-[13px] font-medium truncate leading-tight flex-1">{session.title}</div>
</div>
</button>
<div
className={`absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-md hover:bg-gray-300/50 dark:hover:bg-gray-600 transition-opacity cursor-pointer z-10 ${
activeMenuId === session.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => handleMenuClick(e, session.id)}
>
<MoreHorizontal size={14} className="text-gray-500 dark:text-gray-400" />
</div>
</div>
))}
</div>
</div>
) : (
<div className="mt-2 px-2 flex justify-center flex-shrink-0">
<button
className="!cursor-pointer w-[38px] h-[38px] flex items-center justify-center rounded-lg hover:bg-gray-200/60 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-all duration-200"
onMouseEnter={showRecentPopover}
onMouseLeave={hideRecentPopover}
>
<Clock size={18} />
</button>
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-800">
<button
className="!cursor-pointer w-full flex items-center h-12 px-2 rounded-lg hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-all duration-200 group select-none active:scale-[0.98]"
onMouseEnter={(e) => showTooltip(e, '个人中心')}
onMouseLeave={hideTooltip}
>
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 shadow-sm ring-2 ring-white dark:ring-gray-900 group-hover:ring-transparent transition-all overflow-hidden">
<img src="/vite.svg" alt="User" className="w-5 h-5" />
</div>
<div className={`ml-3 overflow-hidden whitespace-nowrap transition-all duration-300 flex-1 text-left flex flex-col justify-center ${
isCollapsed ? 'w-0 opacity-0 translate-x-[-10px]' : 'w-full opacity-100 translate-x-0'
}`}>
<div className="text-[13px] font-medium text-gray-900 dark:text-gray-100 leading-tight">By_Admin</div>
<div className="text-[11px] text-gray-500">Pro Plan</div>
</div>
{!isCollapsed && <MoreHorizontal size={16} className="text-gray-400 group-hover:text-gray-600 dark:text-gray-500 transition-colors" />}
</button>
</div>
{/* Recent Activity Popover */}
<div
className={`fixed z-[100] w-64 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-200 dark:border-gray-800 py-2 transition-all duration-200 ${
recentPopover.visible && isCollapsed ? 'opacity-100 translate-x-0 pointer-events-auto' : 'opacity-0 -translate-x-2 pointer-events-none'
}`}
style={{ left: 74, top: Math.min(recentPopover.top - 20, typeof window !== 'undefined' ? window.innerHeight - 300 : 0) }}
onMouseEnter={keepRecentPopover}
onMouseLeave={hideRecentPopover}
>
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-800 mb-1">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">最近活动</span>
</div>
<div className="max-h-[300px] overflow-y-auto px-2 space-y-0.5 scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800">
{sessions.map(session => (
<button
key={session.id}
onClick={() => {
onSelectSession(session.id);
setRecentPopover(prev => ({ ...prev, visible: false }));
}}
className={`!cursor-pointer w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
currentSessionId === session.id
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<div className="font-medium truncate">{session.title}</div>
<div className="text-xs text-gray-400 mt-0.5">{formatTime(session.timestamp)}</div>
</button>
))}
{sessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-gray-400">
暂无活动记录
</div>
)}
</div>
</div>
{/* Session Menu */}
{activeMenuId && (
<div
ref={menuRef}
className="fixed z-[110] w-48 bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-800 py-1"
style={{ top: menuPosition.top, left: menuPosition.left }}
>
<div className="px-1 space-y-0.5">
<button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50">
<Share2 size={14} className="mr-2" />
分享
</button>
<button
className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left"
onClick={() => {
const session = sessions.find(s => s.id === activeMenuId);
if (session) openRenameModal(activeMenuId, session.title);
}}
>
<Edit2 size={14} className="mr-2" />
重命名
</button>
<button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50">
<Star size={14} className="mr-2" />
添加到收藏
</button>
<button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50">
<ExternalLink size={14} className="mr-2" />
在新标签页中打开
</button>
<div className="relative group/submenu">
<button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left justify-between cursor-not-allowed opacity-50">
<div className="flex items-center">
<FolderInput size={14} className="mr-2" />
移动到项目
</div>
</button>
</div>
<div className="my-1 h-px bg-gray-200 dark:bg-gray-800" />
<button
className="w-full flex items-center px-2 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors text-left"
onClick={() => openDeleteModal(activeMenuId)}
>
<Trash2 size={14} className="mr-2" />
删除
</button>
</div>
</div>
)}
{/* Modals */}
<Modal
isOpen={renameModal.isOpen}
onClose={() => setRenameModal({ ...renameModal, isOpen: false })}
title="重命名对话"
primaryAction={{
label: '保存',
onClick: handleRenameConfirm
}}
secondaryAction={{
label: '取消',
onClick: () => setRenameModal({ ...renameModal, isOpen: false })
}}
>
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
标题
</label>
<input
type="text"
id="title"
value={newTitleInput}
onChange={(e) => setNewTitleInput(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white"
placeholder="输入新的对话标题"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameConfirm();
}}
/>
</div>
</div>
</Modal>
<Modal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ ...deleteModal, isOpen: false })}
title="删除对话"
primaryAction={{
label: '删除',
onClick: handleDeleteConfirm,
danger: true
}}
secondaryAction={{
label: '取消',
onClick: () => setDeleteModal({ ...deleteModal, isOpen: false })
}}
>
<div className="text-gray-600 dark:text-gray-400">
确定要删除这个对话吗?此操作无法撤销。
</div>
</Modal>
{/* Tooltip */}
<div
className={`fixed z-[100] px-3 py-1.5 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded-md shadow-xl pointer-events-none transform -translate-y-1/2 whitespace-nowrap transition-all duration-200 ${
tooltip.visible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
}`}
style={{ left: 80, top: tooltip.top }}
>
{tooltip.text}
{/* Arrow */}
<div className="absolute top-1/2 -left-1 w-2 h-2 bg-gray-900 dark:bg-gray-100 transform -translate-y-1/2 rotate-45"></div>
</div>
</div>
</>
);
});