/**
* 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);
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; };
}, []);
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
}, [sessions, searchQuery]);
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, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; 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} 条` : ''}}
/>
>
);
}
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const [tools, setTools] = useState>([]);
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}
))}
);
}