Files
DataClaw/frontend/src/components/Sidebar.tsx
T

1140 lines
44 KiB
TypeScript
Raw Normal View History

2026-03-14 15:52:27 +08:00
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
2026-03-29 15:24:08 +08:00
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder, Globe, Bot, Loader2, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
2026-03-14 19:20:37 +08:00
import { useState, useRef, useEffect } from "react";
2026-03-14 22:25:01 +08:00
import { Link, useNavigate, useLocation } from "react-router-dom";
2026-03-21 21:26:57 +08:00
import { useTranslation } from "react-i18next";
2026-03-14 19:20:37 +08:00
import { useAuthStore } from "@/store/authStore";
2026-03-22 16:26:23 +08:00
import { useProjectStore } from "@/store/projectStore";
import { useDashboardStore } from "@/store/dashboardStore";
2026-03-14 22:25:01 +08:00
import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
2026-03-29 15:31:40 +08:00
import { Switch } from "@/components/ui/switch";
2026-03-14 15:52:27 +08:00
2026-03-14 22:25:01 +08:00
interface SessionInfo {
key: string;
created_at: string;
updated_at: string;
2026-03-14 23:15:41 +08:00
alias?: string | null;
pinned?: boolean;
archived?: boolean;
2026-03-14 22:25:01 +08:00
metadata?: {
title?: string;
};
}
2026-03-14 15:52:27 +08:00
2026-03-22 16:26:23 +08:00
function SectionHeader({
2026-03-14 15:52:27 +08:00
title,
count,
2026-03-22 16:26:23 +08:00
isSelectionMode,
setIsSelectionMode,
selectedKeys,
setSelectedKeys,
2026-03-14 15:52:27 +08:00
items,
2026-03-22 16:26:23 +08:00
onBatchDelete
2026-03-14 15:52:27 +08:00
}: {
title: string;
count: number;
2026-03-22 16:26:23 +08:00
isSelectionMode: boolean;
setIsSelectionMode: (val: boolean) => void;
selectedKeys: string[];
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
2026-03-14 22:25:01 +08:00
items: SessionInfo[];
2026-03-15 20:55:42 +08:00
onBatchDelete: (keys: string[]) => void;
2026-03-14 15:52:27 +08:00
}) {
2026-03-21 21:26:57 +08:00
const { t } = useTranslation();
2026-03-15 20:55:42 +08:00
const handleSelectAll = (e: React.MouseEvent) => {
e.stopPropagation();
if (selectedKeys.length === items.length && items.length > 0) {
setSelectedKeys([]);
} else {
setSelectedKeys(items.map(item => item.key));
}
};
const handleInvertSelection = (e: React.MouseEvent) => {
e.stopPropagation();
const allKeys = items.map(item => item.key);
setSelectedKeys(allKeys.filter(key => !selectedKeys.includes(key)));
};
const handleBatchDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (selectedKeys.length === 0) return;
onBatchDelete(selectedKeys);
setSelectedKeys([]);
setIsSelectionMode(false);
};
useEffect(() => {
if (!isSelectionMode) {
setSelectedKeys([]);
}
2026-03-22 16:26:23 +08:00
}, [isSelectionMode, setSelectedKeys]);
2026-03-15 20:55:42 +08:00
2026-03-14 15:52:27 +08:00
return (
2026-03-22 16:26:23 +08:00
<div className="px-3 pt-4 pb-1">
<div className="flex items-center justify-between px-1 group">
2026-03-28 16:25:35 +08:00
<div className="text-[14px] font-semibold text-muted-foreground flex items-center gap-1">
2026-03-14 15:52:27 +08:00
{title}
<span>({count})</span>
</div>
2026-03-15 20:55:42 +08:00
<div className="flex items-center gap-1">
{isSelectionMode ? (
<>
<button
onClick={handleSelectAll}
2026-03-21 21:26:57 +08:00
title={t('selectAllOrCancel')}
2026-03-28 16:25:35 +08:00
className="p-1 hover:bg-muted/80 rounded text-muted-foreground transition-colors"
2026-03-15 20:55:42 +08:00
>
<ListChecks className="h-3.5 w-3.5" />
</button>
<button
onClick={handleInvertSelection}
2026-03-21 21:26:57 +08:00
title={t('invertSelection')}
2026-03-28 16:25:35 +08:00
className="p-1 hover:bg-muted/80 rounded text-muted-foreground transition-colors"
2026-03-15 20:55:42 +08:00
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
onClick={handleBatchDelete}
disabled={selectedKeys.length === 0}
2026-03-21 21:26:57 +08:00
title={t('batchDelete')}
2026-03-15 20:55:42 +08:00
className={`p-1 rounded transition-colors ${
selectedKeys.length > 0
? "hover:bg-red-100 text-red-500"
2026-03-28 16:25:35 +08:00
: "text-muted-foreground/50 cursor-not-allowed"
2026-03-15 20:55:42 +08:00
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setIsSelectionMode(false)}
2026-03-28 16:25:35 +08:00
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-muted/80 rounded text-muted-foreground transition-colors ml-1"
2026-03-15 20:55:42 +08:00
>
2026-03-21 21:26:57 +08:00
{t('cancel')}
2026-03-15 20:55:42 +08:00
</button>
</>
) : (
<button
onClick={() => setIsSelectionMode(true)}
2026-03-28 16:25:35 +08:00
className="p-1 hover:bg-muted/80 rounded text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
2026-03-15 20:55:42 +08:00
style={{ opacity: count > 0 ? undefined : 0 }}
>
<CheckSquare className="h-3.5 w-3.5" />
</button>
)}
</div>
2026-03-14 15:52:27 +08:00
</div>
2026-03-22 16:26:23 +08:00
</div>
);
}
function Section({
items,
onSelect,
onDelete,
onRename,
onTogglePinned,
onToggleArchived,
activeKey,
isSelectionMode,
selectedKeys,
setSelectedKeys
}: {
items: SessionInfo[];
onSelect: (key: string) => void;
onDelete: (key: string) => void;
onRename: (key: string, currentTitle: string) => void;
onTogglePinned: (key: string, pinned: boolean) => void;
onToggleArchived: (key: string, archived: boolean) => void;
activeKey: string | null;
isSelectionMode: boolean;
selectedKeys: string[];
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
}) {
const { t } = useTranslation();
const toggleSelect = (key: string, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedKeys(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
return (
<div className="px-3 pb-2">
<div className="space-y-0 mt-1">
2026-03-14 22:25:01 +08:00
{items.map((item) => {
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
const isActive = activeKey === item.key;
2026-03-15 20:55:42 +08:00
const isSelected = selectedKeys.includes(item.key);
2026-03-14 22:25:01 +08:00
return (
<div
key={item.key}
2026-03-22 16:26:23 +08:00
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
2026-03-28 16:25:35 +08:00
isActive && !isSelectionMode ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
2026-03-15 20:55:42 +08:00
} ${isSelected ? 'bg-indigo-50/50 text-indigo-700' : ''}`}
2026-03-22 16:26:23 +08:00
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
>
2026-03-14 23:22:31 +08:00
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
2026-03-15 20:55:42 +08:00
{isSelectionMode ? (
<span
className="w-4 shrink-0 flex items-center justify-center"
onClick={(e) => toggleSelect(item.key, e)}
>
{isSelected ? (
<CheckSquare className="h-3.5 w-3.5 text-indigo-600" />
) : (
2026-03-28 16:25:35 +08:00
<Square className="h-3.5 w-3.5 text-muted-foreground/50" />
2026-03-15 20:55:42 +08:00
)}
</span>
) : (
<span className="w-4 shrink-0 flex items-center justify-center">
2026-03-28 16:25:35 +08:00
{item.pinned && <Pin className="h-3.5 w-3.5 text-muted-foreground" />}
2026-03-15 20:55:42 +08:00
</span>
)}
2026-03-14 23:22:31 +08:00
<span className="truncate">{displayTitle}</span>
</div>
2026-03-14 22:25:01 +08:00
2026-03-15 20:55:42 +08:00
{!isSelectionMode && (
<DropdownMenu>
2026-03-28 16:25:35 +08:00
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity outline-none">
2026-03-14 22:25:01 +08:00
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
2026-03-14 23:15:41 +08:00
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.key, displayTitle);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.key, displayTitle);
}}
>
2026-03-14 22:25:01 +08:00
<Pencil className="mr-2 h-4 w-4" />
2026-03-21 21:26:57 +08:00
<span>{t('rename')}</span>
2026-03-14 22:25:01 +08:00
</DropdownMenuItem>
2026-03-14 23:15:41 +08:00
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onTogglePinned(item.key, !!item.pinned);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onTogglePinned(item.key, !!item.pinned);
}}
>
<Pin className="mr-2 h-4 w-4" />
2026-03-21 21:26:57 +08:00
<span>{item.pinned ? t('unpin') : t('pin')}</span>
2026-03-14 23:15:41 +08:00
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleArchived(item.key, !!item.archived);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleArchived(item.key, !!item.archived);
}}
>
<Archive className="mr-2 h-4 w-4" />
2026-03-21 21:26:57 +08:00
<span>{item.archived ? t('unarchive') : t('archive')}</span>
2026-03-14 23:15:41 +08:00
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.key);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.key);
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
2026-03-14 22:25:01 +08:00
<Trash2 className="mr-2 h-4 w-4" />
2026-03-21 21:26:57 +08:00
<span>{t('deleteSession')}</span>
2026-03-14 22:25:01 +08:00
</DropdownMenuItem>
</DropdownMenuContent>
2026-03-22 16:26:23 +08:00
</DropdownMenu>
2026-03-15 20:55:42 +08:00
)}
2026-03-14 22:25:01 +08:00
</div>
);
})}
2026-03-14 15:52:27 +08:00
</div>
</div>
);
}
2026-03-22 16:26:23 +08:00
function DashboardSection({
title,
count,
items,
onSelect,
onDelete,
onRename,
onCreate,
activeId
}: {
title: string;
count: number;
items: {id: string, name: string}[];
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, currentName: string) => void;
onCreate: () => void;
activeId: string | null;
}) {
const { t } = useTranslation();
return (
<div className="px-3 pt-4">
<div className="flex items-center justify-between mb-1.5 px-1 group">
2026-03-28 16:25:35 +08:00
<div className="text-[14px] font-semibold text-muted-foreground flex items-center gap-1">
2026-03-22 16:26:23 +08:00
{title}
<span>({count})</span>
</div>
<button
onClick={onCreate}
2026-03-28 16:25:35 +08:00
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-muted/80 rounded text-muted-foreground transition-colors opacity-0 group-hover:opacity-100 flex items-center gap-0.5"
2026-03-22 16:26:23 +08:00
>
<Plus className="h-3.5 w-3.5" />
{t('new')}
</button>
</div>
<div className="space-y-0 mt-1">
{items.map((item) => {
const isActive = activeId === item.id;
return (
<div
key={item.id}
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
2026-03-28 16:25:35 +08:00
isActive ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
2026-03-22 16:26:23 +08:00
}`}
onClick={() => onSelect(item.id)}
>
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
<span className="w-4 shrink-0 flex items-center justify-center">
2026-03-28 16:25:35 +08:00
<LayoutDashboard className="h-3.5 w-3.5 text-muted-foreground" />
2026-03-22 16:26:23 +08:00
</span>
<span className="truncate">{item.name}</span>
</div>
<DropdownMenu>
2026-03-28 16:25:35 +08:00
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity outline-none">
2026-03-22 16:26:23 +08:00
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.id, item.name);
}}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t('rename')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.id);
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
2026-03-22 16:48:41 +08:00
<span>{t('delete')}</span>
2026-03-22 16:26:23 +08:00
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
</div>
);
}
2026-03-14 15:52:27 +08:00
function SidebarBody() {
2026-03-14 19:20:37 +08:00
const navigate = useNavigate();
2026-03-14 22:25:01 +08:00
const location = useLocation();
2026-03-14 19:20:37 +08:00
const { user, logout } = useAuthStore();
2026-03-22 16:26:23 +08:00
const { currentProject } = useProjectStore();
const { dashboards, activeDashboardId, loadDashboards, createDashboard, deleteDashboard, renameDashboard, setActiveDashboard } = useDashboardStore();
2026-03-21 21:26:57 +08:00
const { t, i18n } = useTranslation();
2026-03-14 19:20:37 +08:00
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
2026-03-28 20:25:13 +08:00
const [voiceSettingsOpen, setVoiceSettingsOpen] = useState(false);
2026-03-29 15:31:40 +08:00
const [voiceEnabledDraft, setVoiceEnabledDraft] = useState(false);
2026-03-29 15:24:08 +08:00
const [showKnowledgeSubmenu, setShowKnowledgeSubmenu] = useState(false);
const [showMoreSubmenu, setShowMoreSubmenu] = useState(false);
2026-03-28 20:25:13 +08:00
const [whisperUrlDraft, setWhisperUrlDraft] = useState("");
const [isTestingVoice, setIsTestingVoice] = useState(false);
const [voiceTestStatus, setVoiceTestStatus] = useState<"success" | "error" | null>(null);
const [voiceTestMessage, setVoiceTestMessage] = useState("");
2026-03-14 22:25:01 +08:00
// Session management state
const [sessions, setSessions] = useState<SessionInfo[]>([]);
2026-03-14 23:24:09 +08:00
const [sessionFilter, setSessionFilter] = useState("");
2026-03-14 22:25:01 +08:00
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [sessionToRename, setSessionToRename] = useState<{key: string, title: string} | null>(null);
const [newTitle, setNewTitle] = useState("");
2026-03-22 16:26:23 +08:00
const [activeSelectionMode, setActiveSelectionMode] = useState(false);
const [activeSelectedKeys, setActiveSelectedKeys] = useState<string[]>([]);
const [archivedSelectionMode, setArchivedSelectionMode] = useState(false);
const [archivedSelectedKeys, setArchivedSelectedKeys] = useState<string[]>([]);
const [dashboardRenameDialogOpen, setDashboardRenameDialogOpen] = useState(false);
const [dashboardToRename, setDashboardToRename] = useState<{id: string, name: string} | null>(null);
const [newDashboardName, setNewDashboardName] = useState("");
2026-03-14 22:25:01 +08:00
// Try to parse active session from URL query
const queryParams = new URLSearchParams(location.search);
const activeSessionKey = queryParams.get("session") || "api:default";
2026-03-22 16:26:23 +08:00
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
2026-03-14 22:25:01 +08:00
const fetchSessions = async () => {
try {
2026-03-22 16:48:41 +08:00
const url = currentProject
? `/nanobot/sessions?project_id=${currentProject.id}`
: "/nanobot/sessions";
const data = await api.get<SessionInfo[]>(url);
2026-03-14 22:25:01 +08:00
setSessions(data);
} catch (e) {
console.error("Failed to fetch sessions", e);
}
};
useEffect(() => {
fetchSessions();
2026-03-22 16:48:41 +08:00
}, [location.pathname, location.search, currentProject?.id]);
2026-03-14 23:15:41 +08:00
useEffect(() => {
const onFocus = () => fetchSessions();
const onSessionsChanged = () => fetchSessions();
window.addEventListener("focus", onFocus);
window.addEventListener("nanobot:sessions-changed", onSessionsChanged);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("nanobot:sessions-changed", onSessionsChanged);
};
2026-03-14 22:25:01 +08:00
}, []);
2026-03-14 19:20:37 +08:00
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = () => {
logout();
navigate("/login");
};
2026-03-28 20:25:13 +08:00
const openVoiceSettings = () => {
2026-03-29 15:31:40 +08:00
const enabled = localStorage.getItem("whisper_enabled") === "true";
2026-03-28 20:25:13 +08:00
const saved = (localStorage.getItem("whisper_url") || "").trim();
2026-03-29 15:31:40 +08:00
setVoiceEnabledDraft(enabled);
2026-03-28 20:25:13 +08:00
setWhisperUrlDraft(saved);
setVoiceTestStatus(null);
setVoiceTestMessage("");
setVoiceSettingsOpen(true);
};
const handleSaveVoiceSettings = () => {
2026-03-29 15:31:40 +08:00
localStorage.setItem("whisper_enabled", String(voiceEnabledDraft));
if (!voiceEnabledDraft) {
setVoiceSettingsOpen(false);
return;
}
2026-03-28 20:25:13 +08:00
const normalized = whisperUrlDraft.trim();
if (!normalized) {
alert(t('voiceServerRequired', '请填写语音识别服务地址'));
return;
}
localStorage.setItem("whisper_url", normalized);
setVoiceSettingsOpen(false);
};
const handleTestVoiceConnection = async () => {
2026-03-29 15:31:40 +08:00
if (!voiceEnabledDraft) {
alert(t('voiceInputDisabledHint', '请先开启语音输入'));
return;
}
2026-03-28 20:25:13 +08:00
const normalized = whisperUrlDraft.trim();
if (!normalized) {
alert(t('voiceServerRequired', '请填写语音识别服务地址'));
return;
}
setIsTestingVoice(true);
setVoiceTestStatus(null);
setVoiceTestMessage("");
try {
const response = await fetch(`${normalized.replace(/\/$/, "")}/health`, {
method: "GET",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
setVoiceTestStatus("success");
setVoiceTestMessage(t('voiceConnectionSuccess', '连接成功'));
} catch (error: any) {
setVoiceTestStatus("error");
setVoiceTestMessage(`${t('voiceConnectionFailed', '连接失败')}: ${error?.message || t('unknownError', '未知错误')}`);
} finally {
setIsTestingVoice(false);
}
};
2026-03-14 22:25:01 +08:00
const handleSelectSession = (key: string) => {
navigate(`/?session=${encodeURIComponent(key)}`);
};
2026-03-15 17:05:16 +08:00
const handleNewThread = async () => {
2026-03-14 22:25:01 +08:00
const newSessionId = `api:${Date.now()}`;
2026-03-15 17:05:16 +08:00
try {
2026-03-22 16:48:41 +08:00
const payload = currentProject ? { project_id: currentProject.id } : {};
await api.post(`/nanobot/sessions/${encodeURIComponent(newSessionId)}/ensure`, payload);
2026-03-15 17:05:16 +08:00
await fetchSessions();
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to create session", e);
}
2026-03-14 22:25:01 +08:00
navigate(`/?session=${encodeURIComponent(newSessionId)}`);
};
const handleDeleteSession = async (key: string) => {
2026-03-21 21:26:57 +08:00
if (!window.confirm(t('confirmDeleteSession'))) return;
2026-03-14 22:25:01 +08:00
try {
2026-03-14 23:15:41 +08:00
await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`);
2026-03-14 22:25:01 +08:00
if (activeSessionKey === key) {
navigate("/");
}
fetchSessions();
2026-03-14 23:15:41 +08:00
window.dispatchEvent(new Event("nanobot:sessions-changed"));
2026-03-14 22:25:01 +08:00
} catch (e) {
console.error("Failed to delete session", e);
}
};
2026-03-15 20:55:42 +08:00
const handleBatchDelete = async (keys: string[]) => {
2026-03-21 21:26:57 +08:00
if (!window.confirm(t('confirmBatchDeleteSessions', { count: keys.length }))) return;
2026-03-15 20:55:42 +08:00
try {
await api.post("/nanobot/sessions/batch-delete", { session_ids: keys });
if (keys.includes(activeSessionKey)) {
navigate("/");
}
fetchSessions();
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to batch delete sessions", e);
}
};
2026-03-14 22:25:01 +08:00
const openRenameDialog = (key: string, currentTitle: string) => {
setSessionToRename({ key, title: currentTitle });
setNewTitle(currentTitle);
setRenameDialogOpen(true);
};
const handleRename = async () => {
if (!sessionToRename || !newTitle.trim()) return;
try {
2026-03-14 23:15:41 +08:00
const nextTitle = newTitle.trim();
await api.put(`/nanobot/sessions/${encodeURIComponent(sessionToRename.key)}`, { title: nextTitle });
setSessions((prev) =>
prev.map((item) =>
item.key === sessionToRename.key
? { ...item, alias: nextTitle, metadata: { ...(item.metadata || {}), title: nextTitle } }
: item
)
);
2026-03-14 22:25:01 +08:00
setRenameDialogOpen(false);
fetchSessions();
2026-03-14 23:15:41 +08:00
window.dispatchEvent(new Event("nanobot:sessions-changed"));
2026-03-14 22:25:01 +08:00
} catch (e) {
console.error("Failed to rename session", e);
}
};
2026-03-14 23:15:41 +08:00
const handleTogglePinned = async (key: string, pinned: boolean) => {
const nextPinned = !pinned;
try {
await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { pinned: nextPinned });
setSessions((prev) =>
prev
.map((item) => (item.key === key ? { ...item, pinned: nextPinned } : item))
.sort((a, b) => {
const ap = a.pinned ? 1 : 0;
const bp = b.pinned ? 1 : 0;
if (bp !== ap) return bp - ap;
const aa = a.archived ? 1 : 0;
const ba = b.archived ? 1 : 0;
if (aa !== ba) return aa - ba;
return (b.updated_at || "").localeCompare(a.updated_at || "");
})
);
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to toggle pinned", e);
}
};
const handleToggleArchived = async (key: string, archived: boolean) => {
const nextArchived = !archived;
try {
await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { archived: nextArchived });
setSessions((prev) =>
prev
.map((item) => (item.key === key ? { ...item, archived: nextArchived } : item))
.sort((a, b) => {
const ap = a.pinned ? 1 : 0;
const bp = b.pinned ? 1 : 0;
if (bp !== ap) return bp - ap;
const aa = a.archived ? 1 : 0;
const ba = b.archived ? 1 : 0;
if (aa !== ba) return aa - ba;
return (b.updated_at || "").localeCompare(a.updated_at || "");
})
);
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to toggle archived", e);
}
};
2026-03-14 23:24:09 +08:00
const normalizedFilter = sessionFilter.trim().toLowerCase();
const activeSessions = sessions.filter((item) => {
if (item.archived) return false;
if (!normalizedFilter) return true;
const title = (item.metadata?.title || item.key.replace("api:", "")).toLowerCase();
return title.includes(normalizedFilter);
});
const archivedSessions = sessions.filter((item) => {
if (!item.archived) return false;
if (!normalizedFilter) return true;
const title = (item.metadata?.title || item.key.replace("api:", "")).toLowerCase();
return title.includes(normalizedFilter);
});
2026-03-14 23:22:31 +08:00
2026-03-22 16:26:23 +08:00
const handleCreateDashboard = () => {
if (!currentProject) return;
if (dashboards.length >= 3) {
alert(t('dashboardLimitReached') || "You can only create up to 3 dashboards.");
return;
}
createDashboard(t('newDashboardNameDefault'), currentProject.id);
navigate(`/dashboard`);
};
const handleSelectDashboard = (id: string) => {
setActiveDashboard(id);
navigate(`/dashboard`);
};
const openDashboardRenameDialog = (id: string, name: string) => {
setDashboardToRename({ id, name });
setNewDashboardName(name);
setDashboardRenameDialogOpen(true);
};
const handleDashboardRename = () => {
if (!currentProject || !dashboardToRename || !newDashboardName.trim()) return;
renameDashboard(dashboardToRename.id, newDashboardName.trim(), currentProject.id);
setDashboardRenameDialogOpen(false);
};
const handleDashboardDelete = (id: string) => {
if (!currentProject) return;
if (!window.confirm(t('confirmDeleteDashboard'))) return;
deleteDashboard(id, currentProject.id);
};
const filteredDashboards = dashboards.filter((d) => {
if (!normalizedFilter) return true;
return d.name.toLowerCase().includes(normalizedFilter);
});
2026-03-14 15:52:27 +08:00
return (
2026-03-28 16:25:35 +08:00
<div className="h-full min-h-0 flex flex-col bg-muted/50/30 border-r border-border relative">
2026-03-14 15:52:27 +08:00
{/* Header */}
2026-03-28 16:25:35 +08:00
<div className="h-14 px-4 flex items-center justify-between border-b border-border">
<Link to="/" className="flex items-center gap-1.5 text-foreground/80 font-bold text-lg hover:opacity-80 transition-opacity">
2026-03-14 15:52:27 +08:00
<span className="text-xl leading-none mr-0.5">🦞</span>
2026-03-28 16:25:35 +08:00
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-muted-foreground">
2026-03-21 21:26:57 +08:00
{t('lobsterDataQA')}
2026-03-14 15:52:27 +08:00
</span>
2026-03-14 19:20:37 +08:00
</Link>
2026-03-14 23:24:09 +08:00
<div className="w-8" />
2026-03-14 15:52:27 +08:00
</div>
2026-03-22 16:26:23 +08:00
<div className="flex-none">
<DashboardSection
title={t('dashboards') || 'Dashboards'}
count={filteredDashboards.length}
items={filteredDashboards.map(d => ({ id: d.id, name: d.name }))}
onSelect={handleSelectDashboard}
onDelete={handleDashboardDelete}
onRename={openDashboardRenameDialog}
onCreate={handleCreateDashboard}
activeId={location.pathname === "/dashboard" ? activeDashboardId : null}
/>
2026-03-14 15:52:27 +08:00
2026-03-22 16:26:23 +08:00
<div className="px-3 pt-4 mb-2">
<Button
variant="outline"
2026-03-28 16:25:35 +08:00
className="w-full justify-start h-10 px-3 rounded-lg border-border bg-background hover:bg-muted/50 text-muted-foreground font-medium text-[14px]"
2026-03-22 16:26:23 +08:00
onClick={handleNewThread}
>
<Plus className="h-4 w-4 mr-2" />
{t('newThread')}
</Button>
</div>
2026-03-14 15:52:27 +08:00
2026-03-22 16:26:23 +08:00
<div className="px-3 pt-2">
2026-03-14 23:24:09 +08:00
<div className="relative">
2026-03-28 16:25:35 +08:00
<Search className="h-4 w-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
2026-03-14 23:24:09 +08:00
<Input
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
2026-03-21 21:26:57 +08:00
placeholder={t('filterSessionName')}
2026-03-28 16:25:35 +08:00
className="pl-9 h-9 border-border bg-background text-[14px]"
2026-03-14 23:24:09 +08:00
/>
</div>
</div>
2026-03-22 16:26:23 +08:00
</div>
<div className="flex-1 min-h-0 flex flex-col mt-2">
<div className="flex-1 min-h-0 flex flex-col">
<SectionHeader
title={t('threads')}
count={activeSessions.length}
isSelectionMode={activeSelectionMode}
setIsSelectionMode={setActiveSelectionMode}
selectedKeys={activeSelectedKeys}
setSelectedKeys={setActiveSelectedKeys}
items={activeSessions}
onBatchDelete={handleBatchDelete}
/>
<ScrollArea className="flex-1 min-h-0">
<Section
items={activeSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
isSelectionMode={activeSelectionMode}
selectedKeys={activeSelectedKeys}
setSelectedKeys={setActiveSelectedKeys}
/>
</ScrollArea>
</div>
{archivedSessions.length > 0 && (
2026-03-28 16:25:35 +08:00
<div className="h-[35%] min-h-[150px] shrink-0 border-t border-border bg-muted/50/50 flex flex-col">
2026-03-22 16:26:23 +08:00
<SectionHeader
title={t('archivedThreads')}
count={archivedSessions.length}
isSelectionMode={archivedSelectionMode}
setIsSelectionMode={setArchivedSelectionMode}
selectedKeys={archivedSelectedKeys}
setSelectedKeys={setArchivedSelectedKeys}
items={archivedSessions}
onBatchDelete={handleBatchDelete}
/>
<ScrollArea className="flex-1 min-h-0">
<Section
items={archivedSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
isSelectionMode={archivedSelectionMode}
selectedKeys={archivedSelectedKeys}
setSelectedKeys={setArchivedSelectedKeys}
/>
</ScrollArea>
</div>
)}
</div>
2026-03-14 15:52:27 +08:00
2026-03-14 22:25:01 +08:00
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
2026-03-21 21:26:57 +08:00
<DialogTitle>{t('renameSession')}</DialogTitle>
2026-03-14 22:25:01 +08:00
</DialogHeader>
<div className="py-4">
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
2026-03-21 21:26:57 +08:00
placeholder={t('enterNewSessionTitle')}
2026-03-14 22:25:01 +08:00
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename();
}
}}
/>
</div>
<DialogFooter>
2026-03-21 21:26:57 +08:00
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>{t('cancel')}</Button>
2026-03-28 16:25:35 +08:00
<Button className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" onClick={handleRename}>{t('save')}</Button>
2026-03-14 22:25:01 +08:00
</DialogFooter>
</DialogContent>
</Dialog>
2026-03-22 16:26:23 +08:00
<Dialog open={dashboardRenameDialogOpen} onOpenChange={setDashboardRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('renameDashboard')}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder={t('enterNewDashboardName')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleDashboardRename();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDashboardRenameDialogOpen(false)}>{t('cancel')}</Button>
2026-03-28 16:25:35 +08:00
<Button className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" onClick={handleDashboardRename}>{t('save')}</Button>
2026-03-22 16:26:23 +08:00
</DialogFooter>
</DialogContent>
</Dialog>
2026-03-28 20:25:13 +08:00
<Dialog open={voiceSettingsOpen} onOpenChange={setVoiceSettingsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('voiceSettings', '语音输入配置')}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-3">
2026-03-29 15:31:40 +08:00
<div className="flex items-center justify-between rounded-md border border-border p-3">
<span className="text-sm text-foreground">{t('enableVoiceInput', '启用语音输入')}</span>
<Switch checked={voiceEnabledDraft} onCheckedChange={setVoiceEnabledDraft} />
</div>
2026-03-28 20:25:13 +08:00
<Input
value={whisperUrlDraft}
onChange={(e) => setWhisperUrlDraft(e.target.value)}
placeholder="http://localhost:8001"
2026-03-29 15:31:40 +08:00
disabled={!voiceEnabledDraft}
2026-03-28 20:25:13 +08:00
/>
<p className="text-xs text-muted-foreground">
2026-03-29 15:31:40 +08:00
{voiceEnabledDraft
? t('voiceSettingsHint', '请输入语音识别服务地址,例如:http://localhost:8001')
: t('voiceSettingsDisabledHint', '请先开启语音输入,再配置服务地址')}
2026-03-28 20:25:13 +08:00
</p>
{voiceTestStatus && (
<div className={`flex items-center gap-2 text-xs ${voiceTestStatus === "success" ? "text-emerald-600" : "text-red-600"}`}>
{voiceTestStatus === "success" ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
<span>{voiceTestMessage}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVoiceSettingsOpen(false)}>{t('cancel')}</Button>
<Button variant="outline" onClick={handleTestVoiceConnection} disabled={isTestingVoice}>
{isTestingVoice ? <Loader2 className="h-4 w-4 animate-spin" /> : t('testConnection', '测试连接')}
</Button>
<Button className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" onClick={handleSaveVoiceSettings}>{t('save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2026-03-28 16:25:35 +08:00
<div className="p-4 border-t border-border mt-auto relative" ref={menuRef}>
<div className="flex items-center justify-between text-muted-foreground">
2026-03-14 19:20:37 +08:00
<button
2026-03-28 16:25:35 +08:00
className="flex items-center gap-2 hover:text-foreground transition-colors p-1 rounded-full hover:bg-muted"
2026-03-29 15:24:08 +08:00
onClick={() => {
setShowUserMenu(!showUserMenu);
if (showUserMenu) {
setShowKnowledgeSubmenu(false);
setShowMoreSubmenu(false);
}
}}
2026-03-14 19:20:37 +08:00
>
2026-03-29 17:47:28 +08:00
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 border border-indigo-200 shadow-sm overflow-hidden">
{user?.avatar ? (
<img src={user.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
<User className="h-4.5 w-4.5" />
)}
2026-03-14 15:52:27 +08:00
</div>
2026-03-14 19:20:37 +08:00
<div className="text-sm font-medium truncate max-w-[100px] text-left">
2026-03-21 21:26:57 +08:00
{user?.username || t('defaultUser')}
2026-03-14 19:20:37 +08:00
</div>
2026-03-14 15:52:27 +08:00
</button>
2026-03-16 17:26:02 +08:00
<button
2026-03-28 16:25:35 +08:00
className="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors px-2 py-1.5 rounded-md hover:bg-muted"
2026-03-16 17:26:02 +08:00
onClick={() => navigate("/skills")}
>
2026-03-15 22:22:10 +08:00
<Wand2 className="h-4 w-4" />
2026-03-21 21:26:57 +08:00
{t('skillCenter')}
2026-03-14 15:52:27 +08:00
</button>
</div>
2026-03-14 19:20:37 +08:00
{/* User Settings Popover Menu */}
{showUserMenu && (
2026-03-29 15:24:08 +08:00
<div className="absolute bottom-[72px] left-4 w-56 bg-background rounded-xl shadow-xl border border-border py-1.5 z-50 overflow-visible animate-in fade-in zoom-in duration-200">
2026-03-28 16:25:35 +08:00
<div className="px-3 py-2 border-b border-border mb-1">
<p className="text-sm font-medium text-foreground truncate">{user?.username}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
2026-03-14 19:20:37 +08:00
</div>
2026-03-16 16:12:35 +08:00
<button
2026-03-28 16:25:35 +08:00
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
2026-03-16 16:12:35 +08:00
onClick={() => {
navigate("/projects");
setShowUserMenu(false);
}}
>
2026-03-28 16:25:35 +08:00
<Folder className="h-4 w-4 text-muted-foreground" />
2026-03-21 21:26:57 +08:00
{t('projectManagement')}
2026-03-16 16:12:35 +08:00
</button>
2026-03-29 15:24:08 +08:00
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={() => {
navigate("/model-configs");
setShowUserMenu(false);
}}
>
<Brain className="h-4 w-4 text-muted-foreground" />
{t('modelConfig')}
</button>
)}
2026-03-16 16:12:35 +08:00
2026-03-28 01:01:13 +08:00
<button
2026-03-28 16:25:35 +08:00
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
2026-03-28 01:01:13 +08:00
onClick={() => {
if (currentProject?.id) {
navigate(`/projects/${currentProject.id}/subagents`);
} else {
navigate("/projects");
}
setShowUserMenu(false);
}}
>
2026-03-28 16:25:35 +08:00
<Bot className="h-4 w-4 text-muted-foreground" />
2026-03-28 01:01:13 +08:00
{t('subagents', 'Subagents')}
</button>
2026-03-16 16:12:35 +08:00
<button
2026-03-28 16:25:35 +08:00
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
2026-03-16 16:12:35 +08:00
onClick={() => {
navigate("/datasources");
setShowUserMenu(false);
}}
>
2026-03-28 16:25:35 +08:00
<Database className="h-4 w-4 text-muted-foreground" />
2026-03-21 21:26:57 +08:00
{t('dataSourceManagement')}
2026-03-16 16:12:35 +08:00
</button>
2026-03-29 15:24:08 +08:00
<div
className="relative group/kb"
onMouseEnter={() => setShowKnowledgeSubmenu(true)}
onMouseLeave={() => setShowKnowledgeSubmenu(false)}
2026-03-29 14:44:32 +08:00
>
2026-03-29 15:24:08 +08:00
<button
className="w-full flex items-center justify-between px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={(e) => {
e.preventDefault();
setShowKnowledgeSubmenu(!showKnowledgeSubmenu);
}}
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
{t('knowledgeBaseGroup', 'Knowledge Base')}
</div>
<ChevronRight className={`h-4 w-4 text-muted-foreground transition-transform ${showKnowledgeSubmenu ? 'translate-x-0.5' : ''}`} />
</button>
2026-03-29 14:44:32 +08:00
2026-03-29 15:24:08 +08:00
{showKnowledgeSubmenu && (
<div
className="absolute left-[calc(100%-8px)] top-0 w-48 bg-background border border-border rounded-md shadow-lg py-1 z-[60] animate-in fade-in zoom-in-95 duration-200"
style={{ minWidth: 'max-content' }}
>
<div className="absolute -left-3 top-0 bottom-0 w-4" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/knowledge-bases");
setShowUserMenu(false);
setShowKnowledgeSubmenu(false);
}}
>
{t('knowledgeBases')}
</button>
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/embedding-models");
setShowUserMenu(false);
setShowKnowledgeSubmenu(false);
}}
>
{t('embeddingModels')}
</button>
)}
</div>
)}
</div>
2026-03-14 19:56:34 +08:00
2026-03-29 15:24:08 +08:00
<div
className="relative"
onMouseEnter={() => setShowMoreSubmenu(true)}
onMouseLeave={() => setShowMoreSubmenu(false)}
2026-03-28 20:25:13 +08:00
>
2026-03-29 15:24:08 +08:00
<button
className="w-full flex items-center justify-between px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={(e) => {
e.preventDefault();
setShowMoreSubmenu(!showMoreSubmenu);
}}
>
<div className="flex items-center gap-2">
<MoreVertical className="h-4 w-4 text-muted-foreground" />
{t('moreGroup')}
</div>
<ChevronRight className={`h-4 w-4 text-muted-foreground transition-transform ${showMoreSubmenu ? 'translate-x-0.5' : ''}`} />
</button>
2026-03-28 20:25:13 +08:00
2026-03-29 15:24:08 +08:00
{showMoreSubmenu && (
<div
className="absolute left-[calc(100%-8px)] top-0 w-48 bg-background border border-border rounded-md shadow-lg py-1 z-[60] animate-in fade-in zoom-in-95 duration-200"
style={{ minWidth: 'max-content' }}
2026-03-29 14:44:32 +08:00
>
2026-03-29 15:24:08 +08:00
<div className="absolute -left-3 top-0 bottom-0 w-4" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/settings");
setShowUserMenu(false);
setShowMoreSubmenu(false);
}}
>
{t('personalSettings')}
</button>
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/users");
setShowUserMenu(false);
setShowMoreSubmenu(false);
}}
>
{t('userManagement')}
</button>
)}
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
openVoiceSettings();
setShowUserMenu(false);
setShowMoreSubmenu(false);
}}
>
{t('voiceSettings')}
</button>
2026-03-29 19:34:58 +08:00
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/web-search-config");
setShowUserMenu(false);
setShowMoreSubmenu(false);
}}
>
{t('webSearchConfig', 'Web Search Config')}
</button>
)}
2026-03-29 15:24:08 +08:00
</div>
)}
</div>
2026-03-14 19:20:37 +08:00
2026-03-28 16:25:35 +08:00
<div className="h-px bg-muted my-1 mx-2" />
2026-03-14 19:20:37 +08:00
2026-03-21 21:26:57 +08:00
<button
2026-03-28 16:25:35 +08:00
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
2026-03-21 21:26:57 +08:00
onClick={() => {
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
setShowUserMenu(false);
}}
>
2026-03-28 16:25:35 +08:00
<Globe className="h-4 w-4 text-muted-foreground" />
2026-03-21 21:26:57 +08:00
{i18n.language === 'zh' ? 'English' : '中文'}
</button>
2026-03-14 19:20:37 +08:00
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
onClick={handleLogout}
>
2026-03-21 21:26:57 +08:00
{t('logout')}
2026-03-14 19:20:37 +08:00
</button>
</div>
)}
2026-03-14 15:52:27 +08:00
</div>
</div>
);
}
export function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger render={
2026-03-28 16:25:35 +08:00
<Button variant="ghost" size="icon" className="md:hidden fixed top-3 left-3 z-50 border border-border bg-background">
2026-03-14 15:52:27 +08:00
<Menu className="h-5 w-5" />
</Button>
} />
<SheetContent side="left" className="w-[280px] p-0">
<SidebarBody />
</SheetContent>
</Sheet>
<div className="hidden md:flex w-[280px] h-screen shrink-0">
<SidebarBody />
</div>
</>
);
}