P0 安全与工程基线(止血): - .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401) - 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web), pre-commit 与 CI 门禁恢复拦截能力 - 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、 agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型 - 补装 v0.4.1 声明但未安装的 node-html-parser 依赖 P1 逻辑缺陷修复(跨模块边界): - ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离, abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染) - SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入 ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区) ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件) - main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起) P2 工程强化: - CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁; GITEA_NPM_AUTH secret 注入说明 - 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons (业务代码变更不再使 vendor 缓存失效) - database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建) P3 功能补全: - DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance) - FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 + 存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索 (短语转义防 FTS 运算符注入,按会话聚合展示 snippet) - 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮 文档一致性大扫除: - README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、 删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、 项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、 新增 GITEA_NPM_AUTH 配置说明、测试数 207 - 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、 磁盘文件 2 个现状注记、ipc/*.ts 路径) - eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型 测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例) 验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
/**
|
||
* 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<string, { snippet: string; matchCount: number }>
|
||
>(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<string, { snippet: string; matchCount: number }>();
|
||
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 (
|
||
<Box
|
||
component="aside"
|
||
sx={{
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
bgcolor: 'background.paper',
|
||
borderRight: 1,
|
||
borderColor: 'divider',
|
||
width: LAYOUT.SIDEBAR_WIDTH,
|
||
}}
|
||
>
|
||
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<Button
|
||
variant="outlined"
|
||
fullWidth
|
||
size="small"
|
||
onClick={handleNewSession}
|
||
sx={{ mb: 1.5, fontSize: 12 }}
|
||
>
|
||
<Plus size={14} style={{ marginRight: 8 }} /> 新建会话
|
||
</Button>
|
||
|
||
<TextField
|
||
size="small"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="搜索会话标题与内容..."
|
||
slotProps={{
|
||
input: {
|
||
startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />,
|
||
},
|
||
}}
|
||
sx={{
|
||
mb: 1.5,
|
||
'& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 },
|
||
}}
|
||
/>
|
||
|
||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||
{filteredSessions.length === 0 ? (
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}
|
||
>
|
||
{searchQuery ? '无匹配结果' : '暂无会话'}
|
||
</Typography>
|
||
) : (
|
||
filteredSessions.map((session) => (
|
||
<SessionItem
|
||
key={session.id}
|
||
session={session}
|
||
isActive={session.id === currentSessionId}
|
||
isAgentActive={
|
||
session.id === currentSessionId &&
|
||
(agentStatus === 'thinking' || agentStatus === 'executing')
|
||
}
|
||
contentMatch={contentMatches.get(session.id)}
|
||
onClick={() => {
|
||
setCurrentSession(session.id);
|
||
loadSessionMessages(session.id);
|
||
}}
|
||
/>
|
||
))
|
||
)}
|
||
</Box>
|
||
|
||
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
||
<ToolManagerPanel />
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<ListItemButton
|
||
onClick={onClick}
|
||
selected={isActive}
|
||
dense
|
||
sx={{
|
||
borderRadius: 1.5,
|
||
mb: 0.25,
|
||
px: 1.5,
|
||
py: 0.75,
|
||
borderRight: isActive ? '2px solid' : '2px solid transparent',
|
||
borderColor: isActive ? 'primary.main' : 'transparent',
|
||
'&:hover .delete-btn': { opacity: 1 },
|
||
}}
|
||
>
|
||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||
{session.pinned ? (
|
||
<Pin size={10} style={{ color: '#818cf8' }} />
|
||
) : isAgentActive ? (
|
||
<Badge
|
||
color="success"
|
||
variant="dot"
|
||
sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}
|
||
>
|
||
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
|
||
</Badge>
|
||
) : (
|
||
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
|
||
)}
|
||
</ListItemIcon>
|
||
<ListItemText
|
||
primary={
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
fontSize: 12,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
color: isActive ? 'text.primary' : 'text.secondary',
|
||
}}
|
||
>
|
||
{session.title}
|
||
</Typography>
|
||
}
|
||
secondary={
|
||
<>
|
||
<Typography variant="caption" sx={{ fontSize: 10 }}>
|
||
{formatRelativeTime(session.updatedAt)}
|
||
{session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''}
|
||
</Typography>
|
||
{/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配]) */}
|
||
{contentMatch && (
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
display: 'block',
|
||
fontSize: 10,
|
||
color: 'text.disabled',
|
||
mt: 0.25,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
title={contentMatch.snippet}
|
||
>
|
||
{contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''}
|
||
{contentMatch.snippet}
|
||
</Typography>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
<IconButton
|
||
className="delete-btn"
|
||
size="small"
|
||
onClick={handleDelete}
|
||
sx={{
|
||
opacity: 0,
|
||
transition: 'opacity 150ms',
|
||
color: 'text.disabled',
|
||
'&:hover': { color: 'error.main' },
|
||
width: 20,
|
||
height: 20,
|
||
}}
|
||
>
|
||
<Trash2 size={12} />
|
||
</IconButton>
|
||
</ListItemButton>
|
||
|
||
<Dialog
|
||
open={showDeleteDialog}
|
||
onClose={() => setShowDeleteDialog(false)}
|
||
maxWidth="xs"
|
||
fullWidth
|
||
>
|
||
<DialogTitle sx={{ fontSize: 14 }}>删除会话</DialogTitle>
|
||
<DialogContent>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
确定删除会话「{session.title}」?此操作不可恢复。
|
||
</Typography>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button
|
||
size="small"
|
||
onClick={() => setShowDeleteDialog(false)}
|
||
sx={{ color: 'text.secondary' }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
|
||
删除
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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<string, string> = {
|
||
safe: 'success.main',
|
||
low: 'info.main',
|
||
medium: 'warning.main',
|
||
high: 'error.main',
|
||
};
|
||
const riskLabels: Record<string, string> = {
|
||
safe: 'SAFE',
|
||
low: 'LOW',
|
||
medium: 'MEDIUM',
|
||
high: 'HIGH',
|
||
};
|
||
return (
|
||
<Box>
|
||
<ListItemButton
|
||
onClick={() => setExpanded(!expanded)}
|
||
dense
|
||
sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}
|
||
>
|
||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||
<Wrench size={12} style={{ marginLeft: 4 }} />
|
||
</ListItemIcon>
|
||
<ListItemText
|
||
primary={
|
||
<Typography variant="body2" sx={{ fontSize: 12 }}>
|
||
工具管理
|
||
</Typography>
|
||
}
|
||
/>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}
|
||
>
|
||
{readyCount} 就绪
|
||
</Typography>
|
||
</ListItemButton>
|
||
<Collapse in={expanded}>
|
||
<List
|
||
dense
|
||
disablePadding
|
||
sx={{
|
||
pl: 3,
|
||
maxHeight: 200,
|
||
overflowY: 'auto',
|
||
pr: 0.5,
|
||
'&::-webkit-scrollbar': { width: 6 },
|
||
'&::-webkit-scrollbar-track': { borderRadius: 3 },
|
||
'&::-webkit-scrollbar-thumb': {
|
||
bgcolor: 'divider',
|
||
borderRadius: 3,
|
||
'&:hover': { bgcolor: 'action.hover' },
|
||
},
|
||
}}
|
||
>
|
||
{tools.map((t) => (
|
||
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
||
<Box
|
||
sx={{
|
||
width: 6,
|
||
height: 6,
|
||
borderRadius: '50%',
|
||
bgcolor: t.enabled
|
||
? (riskColors[t.riskLevel] ?? 'text.disabled')
|
||
: 'text.disabled',
|
||
mr: 1,
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
flex: 1,
|
||
fontSize: 11,
|
||
color: t.enabled ? 'text.secondary' : 'text.disabled',
|
||
}}
|
||
>
|
||
{t.name}
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
fontSize: 9,
|
||
color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled',
|
||
}}
|
||
>
|
||
{riskLabels[t.riskLevel] ?? t.riskLevel}
|
||
</Typography>
|
||
</ListItemButton>
|
||
))}
|
||
</List>
|
||
</Collapse>
|
||
</Box>
|
||
);
|
||
}
|