feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
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 成功
This commit is contained in:
@@ -4,14 +4,48 @@
|
||||
* 完全使用 MUI 组件,Table 布局标签-值对。
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
|
||||
import { Activity, Cpu } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
|
||||
import { Activity, Cpu, Bot } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
|
||||
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
|
||||
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity };
|
||||
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = {
|
||||
idle: Activity,
|
||||
thinking: Cpu,
|
||||
executing: Cpu,
|
||||
error: Activity,
|
||||
};
|
||||
|
||||
/** v0.5.0: SubAgent 状态区条目 */
|
||||
interface SubAgentItem {
|
||||
taskId: string;
|
||||
description: string;
|
||||
status: 'delegated' | 'running' | 'completed' | 'error';
|
||||
depth: number;
|
||||
durationMs?: number;
|
||||
iterations?: number;
|
||||
error?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** SubAgent 状态展示上限(防长会话无限累积) */
|
||||
const MAX_SUBAGENT_ITEMS = 20;
|
||||
|
||||
const SUB_STATUS_COLORS: Record<SubAgentItem['status'], string> = {
|
||||
delegated: '#fbbf24',
|
||||
running: '#a855f7',
|
||||
completed: '#34d399',
|
||||
error: '#f87171',
|
||||
};
|
||||
|
||||
const SUB_STATUS_LABELS: Record<SubAgentItem['status'], string> = {
|
||||
delegated: '已委派',
|
||||
running: '运行中',
|
||||
completed: '已完成',
|
||||
error: '失败',
|
||||
};
|
||||
|
||||
export function AgentMonitor(): React.JSX.Element {
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
@@ -21,20 +55,57 @@ export function AgentMonitor(): React.JSX.Element {
|
||||
const maxIterations = useAgentStore((s) => s.maxIterations);
|
||||
const traceSteps = useAgentStore((s) => s.traceSteps);
|
||||
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
|
||||
const sessionId = useAgentStore((s) => s.currentSessionId);
|
||||
|
||||
// v0.5.0: SubAgent 状态(监听主进程生命周期事件,按父会话过滤)
|
||||
const [subAgents, setSubAgents] = useState<SubAgentItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.metona?.agent?.onSubAgentEvent) return;
|
||||
const unsubscribe = window.metona.agent.onSubAgentEvent((e) => {
|
||||
// 只展示当前会话派生的 SubAgent
|
||||
if (!sessionId || e.parentSessionId !== sessionId) return;
|
||||
setSubAgents((prev) => {
|
||||
const next = prev.filter((item) => item.taskId !== e.taskId);
|
||||
next.unshift({
|
||||
taskId: e.taskId,
|
||||
description: e.description,
|
||||
status: e.status,
|
||||
depth: e.depth,
|
||||
durationMs: e.durationMs,
|
||||
iterations: e.iterations,
|
||||
error: e.error,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return next.slice(0, MAX_SUBAGENT_ITEMS);
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId]);
|
||||
|
||||
// 切换会话时清空(新会话的 SubAgent 从零开始)
|
||||
useEffect(() => {
|
||||
setSubAgents([]);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
|
||||
let cancelled = false;
|
||||
if (window.metona?.config?.get) {
|
||||
window.metona.config.get('agent.maxIterations').then((v) => {
|
||||
if (cancelled) return;
|
||||
if (typeof v === 'number' && v > 0) setMaxIterations(v);
|
||||
}).catch((err) => {
|
||||
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
|
||||
console.error('[AgentMonitor] Failed to load maxIterations:', err);
|
||||
});
|
||||
window.metona.config
|
||||
.get('agent.maxIterations')
|
||||
.then((v) => {
|
||||
if (cancelled) return;
|
||||
if (typeof v === 'number' && v > 0) setMaxIterations(v);
|
||||
})
|
||||
.catch((err) => {
|
||||
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
|
||||
console.error('[AgentMonitor] Failed to load maxIterations:', err);
|
||||
});
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [setMaxIterations]);
|
||||
|
||||
// L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
|
||||
@@ -51,24 +122,56 @@ export function AgentMonitor(): React.JSX.Element {
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||
<Cpu size={14} style={{ color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
Agent 状态
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* 状态指示 */}
|
||||
<Box sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1.5,
|
||||
mb: 1.5,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<StatusIcon
|
||||
size={14}
|
||||
style={{
|
||||
color: statusColor,
|
||||
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none',
|
||||
animation:
|
||||
agentStatus === 'thinking' || agentStatus === 'executing'
|
||||
? 'pulse 2s infinite'
|
||||
: 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>{statusLabel}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
{(agentStatus === 'thinking' || agentStatus === 'executing') && (
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor, animation: 'pulse 1.5s infinite', ml: 'auto' }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: statusColor,
|
||||
animation: 'pulse 1.5s infinite',
|
||||
ml: 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -77,33 +180,182 @@ export function AgentMonitor(): React.JSX.Element {
|
||||
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>Provider</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>
|
||||
Provider
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
textAlign: 'right',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 0,
|
||||
}}
|
||||
>
|
||||
{PROVIDER_LABELS[provider] ?? provider}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>模型</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
textAlign: 'right',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 0,
|
||||
}}
|
||||
>
|
||||
{model}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>迭代</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{currentIteration} / {maxIterations}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{totalDuration > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>总耗时</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{formatDuration(totalDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* v0.5.0: SubAgent 状态区 — delegate_task 委派的子任务生命周期 */}
|
||||
{subAgents.length > 0 && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||
<Bot size={14} style={{ color: '#a855f7' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
SubAgent 任务
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||
{subAgents.length} 个
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={0.5} sx={{ maxHeight: 220, overflowY: 'auto' }}>
|
||||
{subAgents.map((sub) => (
|
||||
<Box
|
||||
key={sub.taskId}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: `2px solid ${SUB_STATUS_COLORS[sub.status]}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
bgcolor: SUB_STATUS_COLORS[sub.status],
|
||||
animation: sub.status === 'running' ? 'pulse 1.5s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={sub.description}
|
||||
>
|
||||
{sub.description || sub.taskId}
|
||||
</Typography>
|
||||
{sub.depth > 1 && (
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.disabled' }}>
|
||||
L{sub.depth}
|
||||
</Typography>
|
||||
)}
|
||||
<Chip
|
||||
label={SUB_STATUS_LABELS[sub.status]}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 16,
|
||||
fontSize: 9,
|
||||
flexShrink: 0,
|
||||
bgcolor: SUB_STATUS_COLORS[sub.status] + '22',
|
||||
color: SUB_STATUS_COLORS[sub.status],
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{(sub.durationMs != null || sub.iterations != null) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', fontSize: 9, color: 'text.disabled', mt: 0.25 }}
|
||||
>
|
||||
{sub.durationMs != null ? formatDuration(sub.durationMs) : ''}
|
||||
{sub.iterations != null ? ` · ${sub.iterations} 轮` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
{sub.error && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontSize: 9,
|
||||
color: 'error.main',
|
||||
mt: 0.25,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{sub.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,35 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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';
|
||||
@@ -21,34 +47,125 @@ export function Sidebar(): React.JSX.Element {
|
||||
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);
|
||||
});
|
||||
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; };
|
||||
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(() => {
|
||||
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 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 });
|
||||
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;
|
||||
@@ -56,22 +173,50 @@ export function Sidebar(): React.JSX.Element {
|
||||
// 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; // 不创建本地假会话
|
||||
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 };
|
||||
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
|
||||
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 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
onClick={handleNewSession}
|
||||
sx={{ mb: 1.5, fontSize: 12 }}
|
||||
>
|
||||
<Plus size={14} style={{ marginRight: 8 }} /> 新建会话
|
||||
</Button>
|
||||
|
||||
@@ -79,21 +224,44 @@ export function Sidebar(): React.JSX.Element {
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索会话..."
|
||||
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 } }}
|
||||
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')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} />
|
||||
))}
|
||||
<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 }} />
|
||||
@@ -103,7 +271,19 @@ export function Sidebar(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
|
||||
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) => {
|
||||
@@ -119,9 +299,11 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
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(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => {
|
||||
mod.default.error('删除会话失败,请重试');
|
||||
})
|
||||
.catch(() => {});
|
||||
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
|
||||
setShowDeleteDialog(false);
|
||||
return;
|
||||
@@ -135,7 +317,13 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
traceSteps: [],
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
currentRunId: null,
|
||||
currentIteration: 0,
|
||||
});
|
||||
@@ -145,24 +333,101 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
|
||||
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 } }}>
|
||||
<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' }} />}
|
||||
{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>}
|
||||
<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 }}
|
||||
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>
|
||||
<Dialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>删除会话</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -170,8 +435,16 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}>取消</Button>
|
||||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>删除</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
@@ -180,41 +453,129 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
|
||||
function ToolManagerPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
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; };
|
||||
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' };
|
||||
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
|
||||
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' } } }}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user