/** * Sidebar — 左侧边栏 * * 始终显示,包含会话列表、新建会话按钮、搜索。 */ import { useState, useEffect, useMemo } from 'react'; import { Box, Typography, Button, IconButton, TextField, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions, } from '@mui/material'; import Fuse from 'fuse.js'; import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2, } from 'lucide-react'; import { useSessionStore, type Session } from '@renderer/stores/session-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { formatRelativeTime } from '@renderer/lib/formatters'; import { LAYOUT } from '@renderer/lib/constants'; export function Sidebar(): React.JSX.Element { const sessions = useSessionStore((s) => s.sessions); const currentSessionId = useSessionStore((s) => s.currentSessionId); const setCurrentSession = useSessionStore((s) => s.setCurrentSession); const loadSessionMessages = useAgentStore((s) => s.setCurrentSession); const searchQuery = useSessionStore((s) => s.searchQuery); const setSearchQuery = useSessionStore((s) => s.setSearchQuery); const agentStatus = useAgentStore((s) => s.agentStatus); // v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet } const [contentMatches, setContentMatches] = useState< Map >(new Map()); useEffect(() => { // M-27 修复: 添加 cancelled 标志防止组件卸载后 setState let cancelled = false; if (window.metona?.sessions?.list) { window.metona.sessions .list() .then((list) => { if (cancelled) return; useSessionStore .getState() .setSessions( ( list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean; }> ).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived, })), ); }) .catch((err) => { // M-11 修复: 记录错误而非静默吞掉,便于诊断 console.error('[Sidebar] Failed to load sessions:', err); }); } return () => { cancelled = true; }; }, []); // v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率) useEffect(() => { const q = searchQuery.trim(); if (!q || !window.metona?.sessions?.searchContent) { setContentMatches(new Map()); return; } const timer = window.setTimeout(async () => { try { const r = await window.metona.sessions.searchContent(q); if (r.success && r.data) { const map = new Map(); for (const item of r.data) { map.set(item.sessionId, { snippet: item.snippet, matchCount: item.matchCount }); } setContentMatches(map); } else { setContentMatches(new Map()); } } catch (err) { console.error('[Sidebar] Content search failed:', err); setContentMatches(new Map()); } }, 300); return () => window.clearTimeout(timer); }, [searchQuery]); // L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引 // v0.5.0: 标题匹配(Fuse 模糊)∪ 内容匹配(FTS5 精确短语),标题匹配优先排序 const filteredSessions = useMemo(() => { const list = sessions.filter((s) => !s.archived); if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); const titleMatched = new Set(fuse.search(searchQuery).map((r) => r.item.id)); // 内容匹配的会话并入结果(已含标题匹配的不重复) const merged = list.filter((s) => titleMatched.has(s.id) || contentMatches.has(s.id)); // 标题匹配优先,内容匹配其次(各按置顶/更新时间排序) return merged.sort((a, b) => { const aTitle = titleMatched.has(a.id) ? 0 : 1; const bTitle = titleMatched.has(b.id) ? 0 : 1; if (aTitle !== bTitle) return aTitle - bTitle; return a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1; }); } return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1, ); }, [sessions, searchQuery, contentMatches]); const handleNewSession = async () => { if (window.metona?.sessions?.create) { try { const r = (await window.metona.sessions.create()) as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean; }; useSessionStore .getState() .addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived, }); setCurrentSession(r.id); loadSessionMessages(r.id); return; } catch (err) { // M-9 修复: 显示错误提示而非静默吞错后创建本地假会话 // 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失 console.error('[Sidebar] Failed to create session:', err); import('@metona-team/metona-toast') .then((mod) => { mod.default.error('创建会话失败,请检查数据库状态'); }) .catch(() => {}); return; // 不创建本地假会话 } } const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false, }; useSessionStore.getState().addSession(newSession); setCurrentSession(newSession.id); loadSessionMessages(newSession.id); }; return ( setSearchQuery(e.target.value)} placeholder="搜索会话标题与内容..." slotProps={{ input: { startAdornment: , }, }} sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 }, }} /> {filteredSessions.length === 0 ? ( {searchQuery ? '无匹配结果' : '暂无会话'} ) : ( filteredSessions.map((session) => ( { setCurrentSession(session.id); loadSessionMessages(session.id); }} /> )) )} ); } function SessionItem({ session, isActive, isAgentActive, contentMatch, onClick, }: { session: Session; isActive: boolean; isAgentActive: boolean; contentMatch?: { snippet: string; matchCount: number }; onClick: () => void; }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); setShowDeleteDialog(true); }; const confirmDelete = async () => { // M-10 修复: 乐观更新失败时回滚,避免 UI 与数据库状态不一致 // 之前行为:catch 静默吞错,但下一行已 removeSession,导致用户以为已删除实际未删 if (window.metona?.sessions?.delete) { try { await window.metona.sessions.delete(session.id); } catch (err) { console.error('[Sidebar] Failed to delete session:', err); import('@metona-team/metona-toast') .then((mod) => { mod.default.error('删除会话失败,请重试'); }) .catch(() => {}); // 不调用 removeSession,保留会话在 UI 中(与数据库状态一致) setShowDeleteDialog(false); return; } } useSessionStore.getState().removeSession(session.id); // 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容 // 与 SettingsModal LogsSettings 的 clearSessions 分支一致 if (useAgentStore.getState().currentSessionId === session.id) { useAgentStore.setState({ currentSessionId: null, messages: [], traceSteps: [], tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0, }, currentRunId: null, currentIteration: 0, }); } setShowDeleteDialog(false); }; return ( <> {session.pinned ? ( ) : isAgentActive ? ( ) : ( )} {session.title} } secondary={ <> {formatRelativeTime(session.updatedAt)} {session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''} {/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配]) */} {contentMatch && ( {contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''} {contentMatch.snippet} )} } /> setShowDeleteDialog(false)} maxWidth="xs" fullWidth > 删除会话 确定删除会话「{session.title}」?此操作不可恢复。 ); } function ToolManagerPanel() { const [expanded, setExpanded] = useState(false); const [tools, setTools] = useState< Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean; }> >([]); useEffect(() => { // M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState let cancelled = false; if (window.metona?.tools?.list) { window.metona.tools .list() .then((list) => { if (!cancelled) setTools(list as MetonaToolInfo[]); }) .catch((err) => { console.error('[Sidebar]', err); if (!cancelled) { import('@metona-team/metona-toast') .then((mod) => mod.default.error('加载工具列表失败')) .catch(() => {}); } }); } return () => { cancelled = true; }; }, []); const readyCount = tools.filter((t) => t.enabled).length; const riskColors: Record = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main', }; const riskLabels: Record = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH', }; return ( setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }} > {expanded ? : } 工具管理 } /> 0 ? 'success.main' : 'text.disabled', fontSize: 10 }} > {readyCount} 就绪 {tools.map((t) => ( {t.name} {riskLabels[t.riskLevel] ?? t.riskLevel} ))} ); }