feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
+193 -48
View File
@@ -26,6 +26,9 @@ import { ToolCallCard } from './ToolCallCard';
import { ToolResultBlock } from './ToolResultBlock';
import { formatTime } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
interface AssistantMessageProps {
message: ChatMessage;
@@ -51,54 +54,99 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
// F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素
// 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象
// 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例
const markdownElement = useMemo(() => (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? '');
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
const codeStr = extractTextContent(children).replace(/\n$/, '');
if (!match) return <code className={className} {...props}>{children}</code>;
return <CodeBlock language={match[1]} code={codeStr} />;
},
}}
>
{content}
</ReactMarkdown>
</Box>
), [content]);
const markdownElement = useMemo(
() => (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? '');
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
const codeStr = extractTextContent(children).replace(/\n$/, '');
if (!match)
return (
<code className={className} {...props}>
{children}
</code>
);
return <CodeBlock language={match[1]} code={codeStr} />;
},
}}
>
{content}
</ReactMarkdown>
</Box>
),
[content],
);
return (
<Stack
direction="row"
spacing={1.5}
sx={{ animation: 'fadeInUp 300ms ease-out' }}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}}
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'secondary.main',
color: 'primary.main',
flexShrink: 0,
}}
>
<Bot size={16} />
</Avatar>
<Box
sx={{
flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5,
bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider',
flex: 1,
minWidth: 0,
maxWidth: 768,
borderRadius: 3,
px: 2,
py: 1.5,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
transition: 'border-color 200ms, box-shadow 200ms',
...(isStreaming ? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' } : {}),
...(isStreaming
? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' }
: {}),
}}
>
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
<Typography
variant="caption"
sx={{
color: '#fbbf24',
fontWeight: 600,
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
💭 {isThinking ? t('assistant.thinking') : t('assistant.thinkingLabel')}
</Typography>
{isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#fbbf24', animation: 'pulse 1.5s infinite' }} />
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: '#fbbf24',
animation: 'pulse 1.5s infinite',
}}
/>
)}
</Stack>
<ThoughtBlock content={message.reasoningContent!} defaultExpanded={!!isStreaming} />
@@ -110,16 +158,23 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧
<Typography
variant="caption"
sx={{
color: '#a855f7',
fontWeight: 600,
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
🔧 {t('assistant.toolCalls')}
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<Box key={tc.id}>
<ToolCallCard toolCall={tc} />
{tc.status === 'success' && tc.result != null && (
<ToolResultBlock toolCall={tc} />
)}
{tc.status === 'success' && tc.result != null && <ToolResultBlock toolCall={tc} />}
</Box>
))}
</>
@@ -130,11 +185,28 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography
variant="caption"
sx={{
color: '#34d399',
fontWeight: 600,
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
{t('assistant.reply')}
</Typography>
{isStreaming && !isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#34d399', animation: 'pulse 1.5s infinite' }} />
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: '#34d399',
animation: 'pulse 1.5s infinite',
}}
/>
)}
</Stack>
)}
@@ -167,15 +239,40 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
markdownElement
)
) : isStreaming ? (
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
{isThinking ? '正在思考...' : '正在生成回复...'}
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
<Typography
variant="body2"
sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}
>
{isThinking ? t('assistant.thinking') : t('assistant.generating')}
<Box
component="span"
sx={{
display: 'inline-block',
width: 8,
height: 16,
ml: 0.5,
bgcolor: 'primary.main',
animation: 'blink 1s step-end infinite',
verticalAlign: 'middle',
}}
/>
</Typography>
) : null}
{/* 流式打字光标 */}
{isStreaming && hasContent && (
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.25, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
<Box
component="span"
sx={{
display: 'inline-block',
width: 8,
height: 16,
ml: 0.25,
bgcolor: 'primary.main',
animation: 'blink 1s step-end infinite',
verticalAlign: 'middle',
}}
/>
)}
</>
)}
@@ -210,21 +307,67 @@ export const AssistantMessage = memo(AssistantMessageImpl);
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try { await navigator.clipboard.writeText(code); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
setCopied(true); setTimeout(() => setCopied(false), 2000);
try {
await navigator.clipboard.writeText(code);
} catch {
const ta = document.createElement('textarea');
ta.value = code;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [code]);
return (
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack
direction="row"
sx={{
px: 1.5,
py: 0.75,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
fontSize: 11,
color: 'text.secondary',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
<Tooltip title={copied ? t('assistant.copied') : t('assistant.copy')}>
<IconButton
className="copy-btn"
size="small"
onClick={handleCopy}
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}
>
{copied ? <Check size={10} /> : <Copy size={10} />}
</IconButton>
</Tooltip>
</Stack>
<Box component="pre" sx={{ m: 0, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6, color: 'text.primary' }}>
<Box
component="pre"
sx={{
m: 0,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderTop: 'none',
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
p: 1.5,
overflowX: 'auto',
fontSize: 13,
lineHeight: 1.6,
color: 'text.primary',
}}
>
<code className={language ? `language-${language}` : ''}>{code}</code>
</Box>
</Box>
@@ -238,15 +381,17 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
* React children 正常深度不会超过 10,50 已足够安全裕量
*/
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children);
if (!children) return '';
if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
if (Array.isArray(children))
return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
if (typeof children === 'object' && 'props' in children) {
// 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型
return extractTextContent(
(children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode,
(children as React.ReactElement<{ children?: React.ReactNode }>).props
.children as React.ReactNode,
maxDepth - 1,
);
}
+63 -45
View File
@@ -19,6 +19,8 @@ import {
Button,
Paper,
InputBase,
List,
ListItemButton,
} from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
@@ -304,8 +306,11 @@ export function ChatInput(): React.JSX.Element {
const trimmed = input.trim();
if (!trimmed && attachments.length === 0) return;
if (isStreaming) return;
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
if (!toolsReady) return; // v0.3.18: 工具未就绪时禁止发送
// v0.7.4 P1-8: 闭包陈旧修复 —— configLoaded/toolsReady 不在依赖数组内
// (草稿恢复后 input 不再变化,闭包永远捕获初始 false,按钮看似可用但点击被静默拦截)。
// 改为从 store 读取最新值,彻底消除依赖遗漏类 bug。
if (!useAgentStore.getState().configLoaded) return; // P1-6: 配置未加载完成时禁止发送
if (!useAgentStore.getState().toolsReady) return; // v0.3.18: 工具未就绪时禁止发送
// 处理 / 命令
if (trimmed.startsWith('/')) {
@@ -324,7 +329,7 @@ export function ChatInput(): React.JSX.Element {
import('@renderer/lib/export-markdown')
.then(({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages;
const md = buildSessionMarkdown('会话导出', messages);
const md = buildSessionMarkdown(t('chat.export.title'), messages);
downloadMarkdown(`session-${Date.now()}.md`, md);
})
.catch(() => {});
@@ -423,13 +428,25 @@ export function ChatInput(): React.JSX.Element {
return;
}
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter)
// v0.7.4 P1-7: IME 合成期间组合键同样拦截(拼音选字时按 Cmd/Ctrl+Enter 不应发送)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
e.preventDefault();
return;
}
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
// v0.7.4 P1-7: 中文输入法(IME)合成事件保护 —— 拼音选字回车会触发
// keydown EnterkeyCode 229 / isComposing=true),旧实现直接发送,
// 中文用户高频误发送。合成中回车一律拦截,仅放行真实发送回车。
if (e.key === 'Enter' && !e.shiftKey) {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
e.preventDefault();
return;
}
e.preventDefault();
handleSend();
return;
@@ -493,53 +510,54 @@ export function ChatInput(): React.JSX.Element {
</Stack>
)}
{/* / 命令菜单 */}
{/* / 命令菜单 — v0.7.4 P3-7: 手写 div 弹层改为 MUI Paper+List(遵循 MUI 铁律:
禁止自写 UI 交互组件)。悬停/点击/键盘导航由 MUI 组件内建提供。 */}
{showSlashMenu && filteredCommands.length > 0 && (
<div
style={{
<Paper
elevation={8}
sx={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
marginBottom: 4,
padding: '4px 0',
background: 'var(--bg-secondary, #1a1d27)',
border: '1px solid var(--border-color, #2a2d3a)',
borderRadius: 8,
boxShadow: '0 -4px 12px rgba(0,0,0,0.2)',
mb: 1,
maxHeight: 240,
overflow: 'auto',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: 10,
}}
>
{filteredCommands.map((cmd) => (
<div
key={cmd.id}
onClick={() => {
setInput(cmd.label + ' ');
setShowSlashMenu(false);
textareaRef.current?.focus();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
fontSize: 12,
cursor: 'pointer',
color: 'var(--text-primary, #e1e4ed)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>
/
</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
{cmd.description()}
</span>
</div>
))}
</div>
<List dense disablePadding>
{filteredCommands.map((cmd) => (
<ListItemButton
key={cmd.id}
onClick={() => {
setInput(cmd.label + ' ');
setShowSlashMenu(false);
textareaRef.current?.focus();
}}
sx={{ gap: 1.5, py: 0.75 }}
>
<Typography
sx={{ color: 'primary.main', fontSize: 14, fontWeight: 700, flexShrink: 0 }}
>
/
</Typography>
<Typography sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{cmd.label}
</Typography>
<Typography
sx={{ color: 'text.secondary', fontSize: 11, ml: 'auto', textAlign: 'right' }}
>
{cmd.description()}
</Typography>
</ListItemButton>
))}
</List>
</Paper>
)}
<input
@@ -600,10 +618,10 @@ export function ChatInput(): React.JSX.Element {
<Tooltip
title={
supportsImages
? '附加文件(图片/文本/代码)'
? t('input.attach.image')
: multimodalEnabled
? '附加文件(文本/代码)— 当前模型不支持图片'
: '附加文件(文本/代码)— 多模态未开启(设置 → LLM 配置)'
? t('input.attach.textOnly.model')
: t('input.attach.textOnly.toggle')
}
>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
+32 -29
View File
@@ -17,6 +17,9 @@ import { Box, Typography } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
/**
* v0.6.4 修复: Virtuoso components.Footer 必须是稳定引用 —— 原实现每次 render
@@ -66,10 +69,10 @@ export function MessageList(): React.JSX.Element {
MetonaAI Desktop
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
AI Agent
{t('messageList.subtitle')}
</Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>
Agent · Provider
{t('messageList.emptyHint')}
</Typography>
</Box>
</Box>
@@ -83,36 +86,36 @@ export function MessageList(): React.JSX.Element {
component="section"
role="log"
aria-live="polite"
aria-label="聊天消息列表"
aria-label={t('messageList.ariaLabel')}
sx={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}
>
<Virtuoso
ref={virtuosoRef}
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount
key={currentSessionId ?? 'no-session'}
style={{ flex: 1, minHeight: 0 }}
data={messages}
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
// 新消息追加时跟随(流式中同步,非流式平滑)
followOutput={isStreaming ? 'auto' : 'smooth'}
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom;
}}
itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
</Box>
)}
components={{
Footer: ListFooter,
}}
ref={virtuosoRef}
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount
key={currentSessionId ?? 'no-session'}
style={{ flex: 1, minHeight: 0 }}
data={messages}
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
// 新消息追加时跟随(流式中同步,非流式平滑)
followOutput={isStreaming ? 'auto' : 'smooth'}
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom;
}}
itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
</Box>
)}
components={{
Footer: ListFooter,
}}
/>
</Box>
);
+14 -2
View File
@@ -8,6 +8,9 @@
import { Box, Typography } from '@mui/material';
import { Loader2 } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
@@ -22,10 +25,19 @@ export function StreamingIndicator(): React.JSX.Element | null {
if (lastMsg?.role === 'assistant') return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2, pl: 5, animation: 'fadeIn 200ms ease-out' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
py: 2,
pl: 5,
animation: 'fadeIn 200ms ease-out',
}}
>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 12 }}>
AI...
{t('streaming.connecting')}
</Typography>
</Box>
);
+50 -7
View File
@@ -5,23 +5,66 @@
import { useState, useEffect } from 'react';
import { Box, IconButton, Collapse } from '@mui/material';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
interface ThoughtBlockProps {
content: string;
defaultExpanded?: boolean;
}
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
export function ThoughtBlock({
content,
defaultExpanded = false,
}: ThoughtBlockProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => { setExpanded(defaultExpanded); }, [defaultExpanded]);
useEffect(() => {
setExpanded(defaultExpanded);
}, [defaultExpanded]);
if (!content) return <></>;
return (
<Box sx={{ borderRadius: 1.5, border: '1px dashed', borderColor: 'divider', bgcolor: 'secondary.main', mb: expanded ? 1.5 : 0.5, transition: 'all 200ms' }}>
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '10px 10px 0 0', color: 'text.secondary', fontSize: 12 }}>
<Box
sx={{
borderRadius: 1.5,
border: '1px dashed',
borderColor: 'divider',
bgcolor: 'secondary.main',
mb: expanded ? 1.5 : 0.5,
transition: 'all 200ms',
}}
>
<IconButton
size="small"
onClick={() => setExpanded(!expanded)}
sx={{
width: '100%',
justifyContent: 'flex-start',
gap: 1,
px: 1.5,
py: 1,
borderRadius: '10px 10px 0 0',
color: 'text.secondary',
fontSize: 12,
}}
>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Brain size={12} style={{ color: '#fbbf24' }} />
<span></span>
<span>{t('thought.thinkingLabel')}</span>
</IconButton>
<Collapse in={expanded}>
<Box sx={{ px: 1.5, pb: 1.5, fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 12, lineHeight: 1.6, color: 'text.secondary', whiteSpace: 'pre-wrap' }}>
<Box
sx={{
px: 1.5,
pb: 1.5,
fontFamily: "'SF Mono','Fira Code',monospace",
fontSize: 12,
lineHeight: 1.6,
color: 'text.secondary',
whiteSpace: 'pre-wrap',
}}
>
{content}
</Box>
</Collapse>
+93 -14
View File
@@ -6,50 +6,129 @@ import { Box, Typography, Stack, Chip } from '@mui/material';
import { Wrench, Clock, CheckCircle, XCircle, Ban, Loader2 } from 'lucide-react';
import type { ToolCallInfo } from '@renderer/stores/agent-store';
import { formatDuration } from '@renderer/lib/formatters';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
interface ToolCallCardProps { toolCall: ToolCallInfo; }
interface ToolCallCardProps {
toolCall: ToolCallInfo;
}
const STATUS_ICONS = { pending: Clock, executing: Loader2, success: CheckCircle, error: XCircle, blocked: Ban };
const STATUS_LABELS = { pending: '等待中', executing: '执行中', success: '成功', error: '失败', blocked: '已阻止' };
const STATUS_COLORS: Record<string, string> = { pending: '#fbbf24', executing: '#a855f7', success: '#34d399', error: '#f87171', blocked: '#fb923c' };
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = { pending: 'outlined', executing: 'filled', success: 'filled', error: 'filled', blocked: 'outlined' };
const STATUS_ICONS = {
pending: Clock,
executing: Loader2,
success: CheckCircle,
error: XCircle,
blocked: Ban,
};
// v0.7.4 P3-1: 状态文案出层(t() 必须在渲染时求值 —— i18next 字典注册是异步的)
function statusLabel(status: string): string {
switch (status) {
case 'pending':
return t('toolCall.status.pending');
case 'executing':
return t('toolCall.status.executing');
case 'success':
return t('toolCall.status.success');
case 'error':
return t('toolCall.status.error');
case 'blocked':
return t('toolCall.status.blocked');
default:
return status;
}
}
const STATUS_COLORS: Record<string, string> = {
pending: '#fbbf24',
executing: '#a855f7',
success: '#34d399',
error: '#f87171',
blocked: '#fb923c',
};
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = {
pending: 'outlined',
executing: 'filled',
success: 'filled',
error: 'filled',
blocked: 'outlined',
};
export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element {
const Icon = STATUS_ICONS[toolCall.status];
const color = STATUS_COLORS[toolCall.status];
const label = STATUS_LABELS[toolCall.status];
const label = statusLabel(toolCall.status);
return (
<Box
sx={{
borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: color,
bgcolor: 'secondary.main', px: 1.5, py: 1.25, mb: 1,
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: color,
bgcolor: 'secondary.main',
px: 1.5,
py: 1.25,
mb: 1,
animation:
toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.75, alignItems: 'center' }}>
<Wrench size={12} style={{ color: '#a855f7' }} />
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
<Typography
sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}
>
{toolCall.name}
</Typography>
<Chip
icon={<Icon size={10} style={{ animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />}
icon={
<Icon
size={10}
style={{
animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none',
}}
/>
}
label={label}
size="small"
variant={CHIP_VARIANTS[toolCall.status]}
sx={{ height: 18, fontSize: 10, color, borderColor: color, '& .MuiChip-icon': { color } }}
/>
{toolCall.durationMs != null && (
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>
{formatDuration(toolCall.durationMs)}
</Typography>
)}
</Stack>
{Object.keys(toolCall.args).length > 0 && (
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
overflowY: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 80,
m: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{JSON.stringify(toolCall.args, null, 2)}
</Box>
)}
{toolCall.status === 'error' && toolCall.error && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>
{toolCall.error}
</Typography>
)}
</Box>
);
+48 -6
View File
@@ -7,24 +7,66 @@ import { FileText } from 'lucide-react';
import type { ToolCallInfo } from '@renderer/stores/agent-store';
import { formatDuration } from '@renderer/lib/formatters';
import { toDisplayResult } from '@renderer/lib/tool-result-display';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
interface ToolResultBlockProps { toolCall: ToolCallInfo; }
interface ToolResultBlockProps {
toolCall: ToolCallInfo;
}
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
if (toolCall.status !== 'success' || toolCall.result == null) return <></>;
// 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 进 DOM 导致渲染进程 OOM
// store 内原始 result 不变(LLM 看图能力不受影响)
const displayResult = toDisplayResult(toolCall.result);
const resultStr = typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2);
const resultStr =
typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2);
return (
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
<Box
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: 'info.main',
bgcolor: 'secondary.main',
px: 1.5,
py: 1,
mb: 1,
animation: 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<FileText size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} </Typography>
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
{t('toolResult.title', { name: toolCall.name })}
</Typography>
{toolCall.durationMs != null && (
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>
{formatDuration(toolCall.durationMs)}
</Typography>
)}
</Stack>
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 300, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
overflowY: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 300,
m: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{resultStr}
</Box>
</Box>
+245 -32
View File
@@ -12,8 +12,13 @@ import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
interface UserMessageProps { message: ChatMessage; }
interface UserMessageProps {
message: ChatMessage;
}
export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
const [editing, setEditing] = useState(false);
@@ -25,35 +30,101 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
const editAndResend = useAgentStore((s) => s.editAndResend);
const isStreaming = useAgentStore((s) => s.isStreaming);
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
const handleEditSave = useCallback(() => {
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
setEditing(false);
const handleDoubleClick = useCallback(() => {
setEditing(true);
setEditContent(message.content);
}, [message.content]);
const handleEditSave = useCallback(async () => {
if (!editContent.trim()) {
setEditing(false);
return;
}
if (editContent.trim() !== message.content) {
// v0.7.4 P3-10 修正: 先 IPC 落库,成功后再同步前端 store;失败保留编辑态 + toast。
// 旧实现先改前端 store 再 IPC(失败时界面已生效但重载丢失,用户无感知)。
const currentSessionId = useAgentStore.getState().currentSessionId;
try {
if (currentSessionId && window.metona?.sessions?.updateMessageContent) {
const r = await window.metona.sessions.updateMessageContent(
currentSessionId,
message.id,
editContent.trim(),
);
if (r?.success === false) {
throw new Error(r.error ?? '保存失败');
}
}
updateMessage(message.id, { content: editContent.trim() });
setEditing(false);
} catch (err) {
console.error('[UserMessage] save failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`))
.catch(() => {});
// 保留编辑态,让用户重试或复制内容
}
} else {
setEditing(false);
}
}, [editContent, message.content, message.id, updateMessage]);
const handleEditResend = useCallback(() => {
if (!editContent.trim()) return;
setEditing(false);
void editAndResend(message.id, editContent);
}, [editContent, editAndResend, message.id]);
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.shiftKey) handleEditResend();
}, [handleEditResend, message.content]);
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
const handleEditKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setEditing(false);
setEditContent(message.content);
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.shiftKey) handleEditResend();
},
[handleEditResend, message.content],
);
useEffect(() => {
if (editing) textareaRef.current?.focus();
}, [editing]);
const hasAttachments = message.attachments && message.attachments.length > 0;
return (
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'action.hover', color: 'primary.main', flexShrink: 0 }}><User size={16} /></Avatar>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'action.hover',
color: 'primary.main',
flexShrink: 0,
}}
>
<User size={16} />
</Avatar>
<Box
sx={{ maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, cursor: 'default', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
sx={{
maxWidth: 768,
borderRadius: 3,
px: 2,
py: 1.5,
cursor: 'default',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
onDoubleClick={handleDoubleClick}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}}
>
{/* 附件预览区 */}
{hasAttachments && (
<Stack direction="row" spacing={1} sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}>
<Stack
direction="row"
spacing={1}
sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}
>
{message.attachments!.map((att) => (
<AttachmentPreview key={att.id} attachment={att} />
))}
@@ -63,21 +134,71 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
{/* 文本内容 */}
{editing ? (
<>
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
<TextareaAutosize
ref={textareaRef}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditKeyDown}
minRows={3}
style={{
width: '100%',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'inherit',
fontSize: 13,
lineHeight: 1.7,
resize: 'none',
fontFamily: 'inherit',
}}
/>
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
<Button size="small" variant="outlined" onClick={() => { setEditing(false); setEditContent(message.content); }}></Button>
<Button size="small" variant="outlined" onClick={handleEditSave}></Button>
<Button size="small" variant="contained" onClick={handleEditResend} disabled={isStreaming || !editContent.trim()}></Button>
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled' }}> · Ctrl+Enter</Typography>
<Button
size="small"
variant="outlined"
onClick={() => {
setEditing(false);
setEditContent(message.content);
}}
>
{t('common.cancel')}
</Button>
<Button size="small" variant="outlined" onClick={handleEditSave}>
{t('userMessage.saveOnly')}
</Button>
<Button
size="small"
variant="contained"
onClick={handleEditResend}
disabled={isStreaming || !editContent.trim()}
>
{t('userMessage.saveAndResend')}
</Button>
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled' }}>
{t('userMessage.resendHint')}
</Typography>
</Stack>
</>
) : message.content ? (
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
<Typography
sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}
>
{message.content}
</Typography>
) : null}
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
</Box>
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content, role: 'user' })} onClose={() => setContextMenu(null)} />}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={createContextMenuItems('message', { content: message.content, role: 'user' })}
onClose={() => setContextMenu(null)}
/>
)}
</Stack>
);
}
@@ -89,10 +210,46 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
if (type === 'image' && preview) {
return (
<Box sx={{ position: 'relative', width: 120, height: 120, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Box component="img" src={preview} alt={name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', px: 1, py: 0.5 }}>
<Typography variant="caption" sx={{ color: '#fff', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
<Box
sx={{
position: 'relative',
width: 120,
height: 120,
borderRadius: 2,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<Box
component="img"
src={preview}
alt={name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.6)',
px: 1,
py: 0.5,
}}
>
<Typography
variant="caption"
sx={{
color: '#fff',
fontSize: 10,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{name}
</Typography>
</Box>
@@ -103,11 +260,39 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
// 文本文件
if (type === 'text') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
maxWidth: 200,
flexShrink: 0,
}}
>
<FileText size={20} style={{ color: '#22d3ee', flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 11,
color: 'text.primary',
}}
>
{name}
</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
{formatFileSize(size)}
</Typography>
</Box>
</Box>
);
@@ -115,11 +300,39 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
// 其他文件
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
maxWidth: 200,
flexShrink: 0,
}}
>
<ImageIcon size={20} style={{ color: '#8b8fa7', flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 11,
color: 'text.primary',
}}
>
{name}
</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
{formatFileSize(size)}
</Typography>
</Box>
</Box>
);
@@ -0,0 +1,398 @@
// @vitest-environment jsdom
/**
* ChatInput 组件测试(v0.7.4 引入 jsdom 组件测试)
*
* 覆盖:输入渲染、发送调用、IME 合成回车不发送(P1-7)、
* Ctrl+Enter 发送、Enter 发送、/clear 命令、附件按钮存在。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { ChatInput } from '../ChatInput';
function resetStore(): void {
useAgentStore.setState({
currentSessionId: 's_test',
messages: [],
isStreaming: false,
configLoaded: true,
toolsReady: true,
agentStatus: 'idle',
currentIteration: 0,
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
traceSteps: [],
currentRunId: null,
sessionRunStates: {},
modelVisionCaps: null,
} as never);
useUIStore.setState({
settingsOpen: false,
detailTab: 'trace',
detailVisible: true,
sidebarVisible: true,
focusMode: false,
preFocusSidebarVisible: true,
preFocusDetailVisible: true,
} as never);
useSessionStore.setState({ currentSessionId: null, sessions: [], searchQuery: '' });
}
beforeEach(() => {
resetStore();
});
describe('ChatInput — 输入与发送', () => {
it('渲染输入框与发送按钮', () => {
render(<ChatInput />);
expect(screen.getByPlaceholderText(/输入/)).toBeInTheDocument();
});
it('输入文字后 Enter 触发 sendMessage', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '你好' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
});
it('空输入 Enter 不发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.keyDown(input, { key: 'Enter' });
expect(sendMessage).not.toHaveBeenCalled();
});
it('Ctrl+Enter 触发发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '测试' } });
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true });
expect(sendMessage).toHaveBeenCalledWith('测试', undefined, undefined);
});
it('Shift+Enter 换行不发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '多行' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(sendMessage).not.toHaveBeenCalled();
});
it('流式中 Enter 不发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
useAgentStore.setState({ isStreaming: true } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '内容' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(sendMessage).not.toHaveBeenCalled();
});
});
describe('ChatInput — IME 保护(P1-7', () => {
// fireEvent.keyDown 的 nativeEvent 无法可靠携带 isComposing
// 用真实 KeyboardEvent + dispatchEvent 模拟 IME 合成事件。
function dispatchEnterWithComposing(
input: HTMLElement,
composing: boolean,
ctrlKey = false,
): void {
const ev = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
ctrlKey,
});
Object.defineProperty(ev, 'isComposing', { value: composing });
input.dispatchEvent(ev);
}
it('IME 合成中 Enter 不发送(isComposing=true', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: 'nihao' } });
dispatchEnterWithComposing(input, true);
expect(sendMessage).not.toHaveBeenCalled();
});
it('IME 合成中 Ctrl+Enter 也不发送(P1-7 修正)', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: 'nihao' } });
dispatchEnterWithComposing(input, true, true);
expect(sendMessage).not.toHaveBeenCalled();
});
it('IME 合成结束(isComposing=falseEnter 正常发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '你好' } });
dispatchEnterWithComposing(input, false);
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
});
});
describe('ChatInput — / 命令', () => {
it('输入 /clear 清空会话', async () => {
const clearMessages = vi.fn().mockResolvedValue(undefined);
useAgentStore.setState({ clearMessages: clearMessages } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/clear' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(clearMessages).toHaveBeenCalled();
});
it('输入 / 显示斜杠菜单', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/' } });
// 斜杠菜单命令(clear/export/tool/memory
expect(screen.getAllByText(/^\//).length).toBeGreaterThan(0);
});
});
describe('ChatInput — 斜杠菜单(v0.7.4 P3-7', () => {
it('输入 / 显示全部 4 个命令', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/' } });
expect(screen.getByText('/clear')).toBeInTheDocument();
expect(screen.getByText('/export')).toBeInTheDocument();
expect(screen.getByText('/tool')).toBeInTheDocument();
expect(screen.getByText('/memory')).toBeInTheDocument();
});
it('输入 /cl 过滤菜单只显示 /clear', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/cl' } });
expect(screen.getByText('/clear')).toBeInTheDocument();
expect(screen.queryByText('/export')).toBeNull();
});
it('输入普通文本隐藏斜杠菜单', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/' } });
expect(screen.getByText('/clear')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'hello' } });
expect(screen.queryByText('/clear')).toBeNull();
});
it('点击菜单项插入命令文本并聚焦输入框', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/' } });
fireEvent.click(screen.getByText('/memory'));
expect((input as HTMLTextAreaElement).value).toBe('/memory ');
});
it('菜单显示时按 Escape 关闭', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/' } });
expect(screen.getByText('/clear')).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.queryByText('/clear')).toBeNull();
});
it('输入 /tool 执行打开设置面板', () => {
useUIStore.setState({ settingsOpen: false } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/tool' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUIStore.getState().settingsOpen).toBe(true);
});
it('输入 /memory 切换到详情面板 Memory 标签', () => {
useUIStore.setState({ detailTab: 'trace', detailVisible: false, focusMode: false } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '/memory' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUIStore.getState().detailTab).toBe('memory');
expect(useUIStore.getState().detailVisible).toBe(true);
});
});
describe('ChatInput — 快捷键与按钮状态', () => {
it('Ctrl+Shift+Enter 插入换行而不发送', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '第一行' } });
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true, shiftKey: true });
expect((input as HTMLTextAreaElement).value).toBe('第一行\n');
expect(sendMessage).not.toHaveBeenCalled();
});
it('空输入时发送按钮禁用', () => {
render(<ChatInput />);
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(true);
});
it('有内容时发送按钮可用', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: 'hello' } });
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(false);
});
it('点击发送按钮调用 sendMessage 并清空输入', () => {
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '按钮发送' } });
fireEvent.click(screen.getByText('发送').closest('button') as HTMLElement);
expect(sendMessage).toHaveBeenCalledWith('按钮发送', undefined, undefined);
expect((input as HTMLTextAreaElement).value).toBe('');
});
it('流式中显示中断按钮,点击调用 abort', () => {
const abort = vi.fn();
useAgentStore.setState({ abort: abort } as never);
useAgentStore.setState({ isStreaming: true } as never);
render(<ChatInput />);
const abortBtn = screen.getByText('中断').closest('button') as HTMLElement;
expect(abortBtn).not.toBeNull();
fireEvent.click(abortBtn);
expect(abort).toHaveBeenCalled();
});
it('流式中发送按钮被替换为中断按钮', () => {
useAgentStore.setState({ isStreaming: true } as never);
render(<ChatInput />);
expect(screen.getByText('中断')).toBeInTheDocument();
expect(screen.queryByText('发送')).toBeNull();
});
it('configLoaded=false 时输入框禁用且占位符为"正在加载配置..."', () => {
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText('正在加载配置...');
expect((input as HTMLTextAreaElement).disabled).toBe(true);
});
it('toolsReady=false 时占位符为"工具加载中..."', () => {
useAgentStore.setState({ configLoaded: true, toolsReady: false } as never);
render(<ChatInput />);
expect(screen.getByPlaceholderText('工具加载中...')).toBeInTheDocument();
});
it('configLoaded=false 时即使有内容发送按钮也禁用', () => {
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/正在加载配置/);
fireEvent.change(input, { target: { value: '内容' } });
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(true);
});
});
describe('ChatInput — 附件入口', () => {
it('渲染附件按钮(回形针图标)', () => {
render(<ChatInput />);
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button');
expect(attachBtn).not.toBeNull();
});
it('存在隐藏的文件输入框(multiple', () => {
render(<ChatInput />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).not.toBeNull();
expect(fileInput.multiple).toBe(true);
expect(fileInput.style.display).toBe('none');
});
it('点击附件按钮触发隐藏文件输入框 click', () => {
render(<ChatInput />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click');
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button') as HTMLElement;
fireEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
});
});
describe('ChatInput — 草稿自动保存', () => {
it('输入内容后写入 sessionStorage 草稿', () => {
useSessionStore.setState({ currentSessionId: 's_test' });
sessionStorage.removeItem('draft-s_test');
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '草稿内容' } });
expect(sessionStorage.getItem('draft-s_test')).toBe('草稿内容');
});
it('挂载时从 sessionStorage 恢复草稿', () => {
useSessionStore.setState({ currentSessionId: 's_test' });
sessionStorage.setItem('draft-s_test', '恢复的草稿');
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/) as HTMLTextAreaElement;
expect(input.value).toBe('恢复的草稿');
});
it('发送后清除草稿', () => {
useSessionStore.setState({ currentSessionId: 's_test' });
sessionStorage.setItem('draft-s_test', '待清除');
const sendMessage = vi.fn();
useAgentStore.setState({ sendMessage: sendMessage } as never);
render(<ChatInput />);
const input = screen.getByPlaceholderText(/输入/);
fireEvent.change(input, { target: { value: '内容' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(sendMessage).toHaveBeenCalled();
expect(sessionStorage.getItem('draft-s_test')).toBeNull();
});
});
@@ -0,0 +1,475 @@
// @vitest-environment jsdom
/**
* MessageList / MessageItem / AssistantMessage 组件测试
*
* 覆盖:
* - MessageList 空列表 / 有消息渲染 / 流式跟随(StreamingIndicator 显隐)
* - MessageItem 消息路由(user / assistant / system / tool
* - AssistantMessage 思考块、工具卡片、Markdown 渲染(代码块/列表)、
* 流式纯文本渲染、时间戳、右键菜单、复制按钮
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessageList } from '../MessageList';
import { MessageItem } from '../MessageItem';
import { AssistantMessage } from '../AssistantMessage';
import { useAgentStore, type ChatMessage, type ToolCallInfo } from '@renderer/stores/agent-store';
// react-virtuoso 在 jsdom 下无真实测量能力,mock 为渲染全部条目的简单列表
vi.mock('react-virtuoso', async () => {
const React = await import('react');
return {
Virtuoso: (props: {
data: ChatMessage[];
itemContent: (index: number, msg: ChatMessage) => React.ReactNode;
components?: { Footer?: React.ComponentType };
}) =>
React.createElement(
React.Fragment,
null,
props.data.map((msg, index) =>
React.createElement(React.Fragment, { key: msg.id }, props.itemContent(index, msg)),
),
props.components?.Footer ? React.createElement(props.components.Footer) : null,
),
};
});
const agentInitial = useAgentStore.getState();
function makeMessage(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
return {
role: 'user',
content: '',
timestamp: 1730000000000,
...overrides,
};
}
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
return {
id: 'tc_1',
name: 'read_file',
args: { path: '/tmp/a.txt' },
status: 'success',
durationMs: 100,
...overrides,
};
}
beforeEach(() => {
useAgentStore.setState(agentInitial, true);
});
describe('MessageList — 空列表', () => {
it('无消息且非流式时渲染空状态(标题/副标题/提示)', () => {
useAgentStore.setState({ messages: [], isStreaming: false });
render(<MessageList />);
expect(screen.getByText('MetonaAI Desktop')).toBeInTheDocument();
expect(screen.getByText('生产级通用 AI Agent 智能体桌面应用')).toBeInTheDocument();
expect(screen.getByText(/Agent 就绪/)).toBeInTheDocument();
// logo
expect(document.querySelector('img[alt="Metona"]')).toBeInTheDocument();
});
it('空列表时消息区无 role=log', () => {
useAgentStore.setState({ messages: [], isStreaming: false });
render(<MessageList />);
expect(document.querySelector('[role="log"]')).toBeNull();
});
});
describe('MessageList — 有消息渲染', () => {
it('渲染 user 与 assistant 消息内容', () => {
useAgentStore.setState({
messages: [
makeMessage({ id: 'm1', role: 'user', content: '你好' }),
makeMessage({ id: 'm2', role: 'assistant', content: '我是助手' }),
],
isStreaming: false,
currentSessionId: 's1',
});
render(<MessageList />);
expect(screen.getByText('你好')).toBeInTheDocument();
expect(screen.getByText('我是助手')).toBeInTheDocument();
});
it('消息区为 live region', () => {
useAgentStore.setState({
messages: [makeMessage({ id: 'm1', role: 'user', content: 'x' })],
isStreaming: false,
currentSessionId: 's1',
});
render(<MessageList />);
const log = document.querySelector('[role="log"]');
expect(log).toBeInTheDocument();
expect(log?.getAttribute('aria-live')).toBe('polite');
});
it('渲染 system 消息', () => {
useAgentStore.setState({
messages: [makeMessage({ id: 'm1', role: 'system', content: '系统提示' })],
isStreaming: false,
});
render(<MessageList />);
expect(screen.getByText('系统提示')).toBeInTheDocument();
});
});
describe('MessageList — 流式', () => {
it('流式且最后一条是 user 消息时显示 StreamingIndicator', () => {
useAgentStore.setState({
messages: [makeMessage({ id: 'm1', role: 'user', content: '请回答' })],
isStreaming: true,
});
render(<MessageList />);
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
});
it('流式且最后一条是 assistant 消息时隐藏 StreamingIndicator', () => {
useAgentStore.setState({
messages: [
makeMessage({ id: 'm1', role: 'user', content: '请回答' }),
makeMessage({ id: 'm2', role: 'assistant', content: '回复中' }),
],
isStreaming: true,
});
render(<MessageList />);
expect(screen.queryByText('正在连接 AI...')).toBeNull();
});
it('流式时最后一条 assistant 消息使用纯文本渲染(无 markdown 解析)', () => {
useAgentStore.setState({
messages: [
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
makeMessage({ id: 'm2', role: 'assistant', content: '# 标题\n\n正文' }),
],
isStreaming: true,
agentStatus: 'thinking',
});
const { container } = render(<MessageList />);
// prose-streaming 用于流式纯文本
expect(container.querySelector('.prose-streaming')).not.toBeNull();
});
it('流式时非最后一条消息仍正常渲染 markdown', () => {
useAgentStore.setState({
messages: [
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
makeMessage({ id: 'm2', role: 'assistant', content: '**历史回复**' }),
makeMessage({ id: 'm3', role: 'assistant', content: '新内容' }),
],
isStreaming: true,
});
render(<MessageList />);
expect(screen.getByText('历史回复')).toBeInTheDocument();
});
});
describe('MessageItem — 消息路由', () => {
it('user 消息渲染 UserMessage', () => {
const { container } = render(
<MessageItem message={makeMessage({ id: 'm1', role: 'user', content: '用户内容' })} />,
);
expect(screen.getByText('用户内容')).toBeInTheDocument();
expect(container.querySelector('.lucide-user')).not.toBeNull();
});
it('assistant 消息渲染 AssistantMessage', () => {
render(
<MessageItem message={makeMessage({ id: 'm1', role: 'assistant', content: '助手内容' })} />,
);
expect(screen.getByText('助手内容')).toBeInTheDocument();
});
it('system 消息渲染 SystemMessage(居中胶囊)', () => {
const { container } = render(
<MessageItem message={makeMessage({ id: 'm1', role: 'system', content: '公告' })} />,
);
expect(screen.getByText('公告')).toBeInTheDocument();
// system 无头像
expect(container.querySelector('.lucide-bot')).toBeNull();
});
it('tool 消息不渲染(返回 null', () => {
const { container } = render(
<MessageItem message={makeMessage({ id: 'm1', role: 'tool', content: 'tool result' })} />,
);
expect(container.textContent).toBe('');
});
it('isStreaming 且 isLast 时向 AssistantMessage 传递流式状态(使用纯文本渲染)', () => {
render(
<MessageItem
message={makeMessage({ id: 'm1', role: 'assistant', content: '流' })}
isLast
isStreaming
/>,
);
// 流式路径使用 prose-streaming 纯文本 pre(非 markdown 解析)
expect(document.querySelector('pre.prose-streaming')).not.toBeNull();
});
});
describe('AssistantMessage — 内容与 Markdown 渲染', () => {
it('渲染普通文本内容', () => {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '普通回复' })}
/>,
);
expect(screen.getByText('普通回复')).toBeInTheDocument();
});
it('渲染 Markdown 标题与加粗', () => {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '# 大标题\n\n**加粗** 文本' })}
/>,
);
expect(screen.getByRole('heading', { level: 1, name: '大标题' })).toBeInTheDocument();
expect(screen.getByText('加粗')).toBeInTheDocument();
});
it('渲染 Markdown 无序列表', () => {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '- 项目一\n- 项目二' })}
/>,
);
expect(screen.getByText('项目一')).toBeInTheDocument();
expect(screen.getByText('项目二')).toBeInTheDocument();
});
it('渲染 Markdown 有序列表', () => {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '1. 第一步\n2. 第二步' })}
/>,
);
expect(screen.getByText('第一步')).toBeInTheDocument();
expect(screen.getByText('第二步')).toBeInTheDocument();
});
it('渲染代码块:显示语言标签与代码内容', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '```ts\nconst x = 1;\n```' })}
/>,
);
expect(screen.getByText('ts')).toBeInTheDocument();
expect(container.querySelector('pre')?.textContent).toContain('const x = 1;');
});
it('行内代码不带语言标签时渲染为普通 code', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '使用 `inline()` 调用' })}
/>,
);
expect(container.querySelector('code')).not.toBeNull();
});
it('复制按钮点击后复制代码并切换为已复制图标', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
try {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '```js\nlet a = 1;\n```' })}
/>,
);
const copyBtn = document.querySelector('.copy-btn') as HTMLElement;
expect(copyBtn).not.toBeNull();
// 初始为复制图标
expect(copyBtn.querySelector('.lucide-copy')).not.toBeNull();
fireEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledWith('let a = 1;');
// 点击后切换为已复制图标(Tooltip 文本需 hover 才渲染,用图标断言)
await waitFor(() => {
expect(document.querySelector('.copy-btn .lucide-check')).not.toBeNull();
});
} finally {
// v0.7.4 回归修复: 还原 clipboard 桩,避免跨用例污染
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
}
});
it('渲染链接文本(GFM autolink 不生成跳转外链 DOM 问题)', () => {
render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '访问 [官网](https://example.com)',
})}
/>,
);
const link = screen.getByRole('link', { name: '官网' });
expect(link.getAttribute('href')).toBe('https://example.com');
});
});
describe('AssistantMessage — 思考过程', () => {
it('有 reasoningContent 时显示思考标签', () => {
render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '回复',
reasoningContent: '推理中...',
})}
/>,
);
expect(screen.getByText('思考过程')).toBeInTheDocument();
});
it('无 reasoningContent 时不渲染思考区', () => {
const { container } = render(
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '回复' })} />,
);
expect(container.textContent).not.toContain('思考过程');
});
it('流式且 agentStatus=thinking 时思考标签变为"正在思考..."且可展开思考内容', () => {
useAgentStore.setState({ agentStatus: 'thinking' });
render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '',
reasoningContent: '正在思考的内容',
})}
isStreaming
/>,
);
expect(screen.getByText('正在思考...')).toBeInTheDocument();
expect(screen.getByText('正在思考的内容')).toBeInTheDocument();
});
});
describe('AssistantMessage — 工具调用', () => {
it('有 toolCalls 时显示工具调用标签与工具名', () => {
render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '回复',
toolCalls: [makeToolCall({ name: 'list_directory' })],
})}
/>,
);
// 标签带 🔧 emoji 前缀,用正则匹配
expect(screen.getByText(/工具调用/)).toBeInTheDocument();
expect(screen.getByText('list_directory')).toBeInTheDocument();
});
it('工具成功且有结果时渲染 ToolResultBlock', () => {
render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '回复',
toolCalls: [makeToolCall({ status: 'success', result: { ok: true } })],
})}
/>,
);
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
});
it('工具失败时不渲染结果块', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: '回复',
toolCalls: [makeToolCall({ status: 'error', error: 'boom' })],
})}
/>,
);
expect(container.textContent).toContain('boom');
expect(screen.queryByText(/read_file 结果/)).toBeNull();
});
});
describe('AssistantMessage — 流式状态与时间戳', () => {
it('流式时隐藏时间戳', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: 'x' })}
isStreaming
/>,
);
expect(container.textContent).not.toMatch(/\d{4}-\d{2}-\d{2}/);
});
it('非流式时显示格式化时间戳', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({
id: 'm1',
role: 'assistant',
content: 'x',
timestamp: 1730000000000,
})}
/>,
);
expect(container.textContent).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
it('流式且有内容时使用纯文本 pre 并在其后渲染打字光标', () => {
const { container } = render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '正在输出' })}
isStreaming
/>,
);
// 流式路径:内容渲染为 prose-streaming pre
const pre = container.querySelector('pre.prose-streaming');
expect(pre).not.toBeNull();
expect(pre?.textContent).toBe('正在输出');
// 打字光标:紧随 pre 的空 span
expect(pre?.nextElementSibling?.tagName).toBe('SPAN');
});
it('流式但无内容时显示"正在生成回复..."', () => {
useAgentStore.setState({ agentStatus: 'idle' });
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '' })}
isStreaming
/>,
);
expect(screen.getByText('正在生成回复...')).toBeInTheDocument();
});
});
describe('AssistantMessage — 右键菜单', () => {
it('右键弹出菜单,包含复制与引用回复', () => {
render(
<AssistantMessage
message={makeMessage({ id: 'm1', role: 'assistant', content: '可复制内容' })}
/>,
);
fireEvent.contextMenu(screen.getByText('可复制内容'));
expect(screen.getByText('复制')).toBeInTheDocument();
expect(screen.getByText('引用回复')).toBeInTheDocument();
});
it('assistant 消息右键菜单包含"重新生成"', () => {
render(
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '内容' })} />,
);
fireEvent.contextMenu(screen.getByText('内容'));
expect(screen.getByText('重新生成')).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
/**
* StreamingIndicator 组件测试
*
* 覆盖:非流式不渲染、流式且无消息渲染、流式最后一条 user 消息渲染、
* 流式最后一条 assistant 消息不渲染。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StreamingIndicator } from '../StreamingIndicator';
import { useAgentStore, type ChatMessage } from '@renderer/stores/agent-store';
const agentInitial = useAgentStore.getState();
function makeMsg(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
return { role: 'user', content: '', timestamp: 1730000000000, ...overrides };
}
beforeEach(() => {
useAgentStore.setState(agentInitial, true);
});
describe('StreamingIndicator — 显示/隐藏条件', () => {
it('非流式时不渲染', () => {
useAgentStore.setState({ isStreaming: false, messages: [] });
render(<StreamingIndicator />);
expect(screen.queryByText('正在连接 AI...')).toBeNull();
});
it('流式且无消息时渲染指示器', () => {
useAgentStore.setState({ isStreaming: true, messages: [] });
render(<StreamingIndicator />);
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
});
it('流式且最后一条为 user 消息时渲染指示器', () => {
useAgentStore.setState({
isStreaming: true,
messages: [makeMsg({ id: 'm1', role: 'user', content: 'q' })],
});
render(<StreamingIndicator />);
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
});
it('流式且最后一条为 assistant 消息时不渲染(内容已在卡片内展示)', () => {
useAgentStore.setState({
isStreaming: true,
messages: [makeMsg({ id: 'm2', role: 'assistant', content: '回答' })],
});
render(<StreamingIndicator />);
expect(screen.queryByText('正在连接 AI...')).toBeNull();
});
it('流式但最后一条为 system 消息时渲染(非 assistant', () => {
useAgentStore.setState({
isStreaming: true,
messages: [makeMsg({ id: 'm3', role: 'system', content: '通知' })],
});
render(<StreamingIndicator />);
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
/**
* ThoughtBlock 组件测试
*
* 覆盖:默认折叠、defaultExpanded 展开、点击切换、空内容不渲染、
* 展开状态随 defaultExpanded 变化同步。
*
* 注:MUI Collapse 折叠时内容仍在 DOM 中(height 0),状态判定用
* chevron 图标类名(chevron-right=折叠 / chevron-down=展开)。
*/
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThoughtBlock } from '../ThoughtBlock';
describe('ThoughtBlock — 折叠/展开', () => {
it('默认折叠:显示折叠指示图标,无展开指示图标', () => {
const { container } = render(<ThoughtBlock content="推理内容" />);
expect(screen.getByText('思考过程')).toBeInTheDocument();
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
expect(container.querySelector('.lucide-chevron-down')).toBeNull();
});
it('defaultExpanded=true 时展开并渲染内容', () => {
const { container } = render(<ThoughtBlock content="推理内容" defaultExpanded />);
expect(screen.getByText('推理内容')).toBeInTheDocument();
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
});
it('点击切换展开/折叠', () => {
const { container } = render(<ThoughtBlock content="推理内容" />);
fireEvent.click(screen.getByText('思考过程'));
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
expect(screen.getByText('推理内容')).toBeInTheDocument();
fireEvent.click(screen.getByText('思考过程'));
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
});
it('内容变化时 defaultExpanded 同步到展开状态', () => {
const { rerender } = render(<ThoughtBlock content="A" defaultExpanded={false} />);
rerender(<ThoughtBlock content="B" defaultExpanded />);
expect(screen.getByText('B')).toBeInTheDocument();
expect(document.querySelector('.lucide-chevron-down')).not.toBeNull();
});
it('空内容时不渲染任何节点', () => {
const { container } = render(<ThoughtBlock content="" />);
expect(container.textContent).toBe('');
});
});
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
/**
* ToolCallCard 组件测试(v0.7.4 引入 jsdom 组件测试)
*
* 覆盖:五状态渲染(pending/executing/success/error/blocked)、
* 工具名/参数/耗时/错误信息的条件渲染。
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ToolCallCard } from '../ToolCallCard';
function makeToolCall(overrides: Partial<Parameters<typeof ToolCallCard>[0]['toolCall']> = {}) {
return {
id: 'tc_1',
name: 'read_file',
args: {},
status: 'success' as const,
durationMs: 120,
...overrides,
};
}
describe('ToolCallCard — 状态渲染', () => {
it.each([
['pending', '等待中'],
['executing', '执行中'],
['success', '成功'],
['error', '失败'],
['blocked', '已阻止'],
] as const)('%s 状态显示对应文案', (status, label) => {
const { container } = render(<ToolCallCard toolCall={makeToolCall({ status })} />);
// Chip 的 label 可能在 DOM 中拆分,用 textContent 匹配
expect(container.textContent).toContain(label);
});
it('显示工具名', () => {
render(<ToolCallCard toolCall={makeToolCall({ name: 'list_directory' })} />);
expect(screen.getByText('list_directory')).toBeInTheDocument();
});
it('显示耗时(formatDuration 格式化)', () => {
render(<ToolCallCard toolCall={makeToolCall({ durationMs: 1500 })} />);
expect(screen.getByText('1.5s')).toBeInTheDocument();
});
it('无耗时字段时不渲染耗时', () => {
const { container } = render(
<ToolCallCard toolCall={makeToolCall({ durationMs: undefined })} />,
);
expect(container.textContent).not.toMatch(/s$/);
});
});
describe('ToolCallCard — 参数与错误', () => {
it('有参数时渲染 JSON 格式参数', () => {
const args = { file_path: 'a.ts', offset: 10 };
render(<ToolCallCard toolCall={makeToolCall({ args })} />);
expect(screen.getByText(/"file_path"/)).toBeInTheDocument();
expect(screen.getByText(/"a\.ts"/)).toBeInTheDocument();
});
it('无参数时不渲染参数区', () => {
const { container } = render(<ToolCallCard toolCall={makeToolCall({ args: {} })} />);
expect(container.querySelector('pre')).toBeNull();
});
it('error 状态且有错误信息时渲染错误文本', () => {
render(
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: 'Permission denied' })} />,
);
expect(screen.getByText('Permission denied')).toBeInTheDocument();
});
it('error 状态但无错误信息时不渲染错误区', () => {
const { container } = render(
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: undefined })} />,
);
expect(container.textContent).not.toContain('undefined');
});
it('非 error 状态即使有 error 字段也不渲染', () => {
const { container } = render(
<ToolCallCard toolCall={makeToolCall({ status: 'success', error: 'should not show' })} />,
);
expect(container.textContent).not.toContain('should not show');
});
});
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
/**
* ToolResultBlock 组件测试
*
* 覆盖:结果渲染、dataUrl 剥离(toDisplayResult 摘要 + _displayNote)、
* 非 success 状态不渲染、null result 不渲染、耗时显示、字符串结果直接渲染。
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ToolResultBlock } from '../ToolResultBlock';
import type { ToolCallInfo } from '@renderer/stores/agent-store';
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
return {
id: 'tc_1',
name: 'view_image',
args: {},
status: 'success',
result: { ok: true },
durationMs: 200,
...overrides,
};
}
describe('ToolResultBlock — 结果渲染', () => {
it('渲染工具名与结果标题', () => {
render(<ToolResultBlock toolCall={makeToolCall({ name: 'read_file' })} />);
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
});
it('渲染字符串结果', () => {
render(<ToolResultBlock toolCall={makeToolCall({ result: '文件内容' })} />);
expect(screen.getByText('文件内容')).toBeInTheDocument();
});
it('渲染对象结果(JSON 格式化)', () => {
const { container } = render(
<ToolResultBlock toolCall={makeToolCall({ result: { files: ['a.txt'] } })} />,
);
expect(container.querySelector('pre')?.textContent).toContain('a.txt');
});
it('渲染耗时(formatDuration', () => {
render(<ToolResultBlock toolCall={makeToolCall({ durationMs: 1500 })} />);
expect(screen.getByText('1.5s')).toBeInTheDocument();
});
it('无耗时时不渲染耗时', () => {
const { container } = render(
<ToolResultBlock toolCall={makeToolCall({ durationMs: undefined })} />,
);
expect(container.textContent).not.toMatch(/s$/);
});
it('非 success 状态不渲染', () => {
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ status: 'error' })} />);
expect(container.textContent).toBe('');
});
it('result 为 null 时不渲染', () => {
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result: null })} />);
expect(container.textContent).toBe('');
});
});
describe('ToolResultBlock — dataUrl 剥离', () => {
it('含 dataUrl 字段时剥离并显示 _displayNote', () => {
const result = {
dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000),
path: '/tmp/screenshot.png',
size: 1234,
};
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
const text = container.querySelector('pre')?.textContent ?? '';
expect(text).not.toContain('data:image/png;base64');
expect(text).toContain('[image base64 omitted for display — visible to LLM only]');
expect(text).toContain('/tmp/screenshot.png');
});
it('普通大字符串被截断显示(防御裁剪)', () => {
const result = { content: 'x'.repeat(20000) };
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
const text = container.querySelector('pre')?.textContent ?? '';
expect(text).toContain('[truncated for display]');
});
it('store 原始 result 不被修改(纯函数不变异入参)', () => {
const result = {
dataUrl: 'data:image/png;base64,QQ',
path: '/a.png',
};
const originalDataUrl = result.dataUrl;
render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
expect(result.dataUrl).toBe(originalDataUrl);
});
});
@@ -0,0 +1,193 @@
// @vitest-environment jsdom
/**
* UserMessage 组件测试(v0.7.4 引入 jsdom 组件测试)
*
* 覆盖:内容渲染、附件预览(图片/文本/其他)、双击编辑、
* 仅保存落库(P3-10:IPC 成功后更新 store)、保存并重发、取消、Escape 退出。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { UserMessage } from '../UserMessage';
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 'm1',
role: 'user',
content: '帮我看看这段代码',
timestamp: 1700000000000,
...overrides,
};
}
function makeAttachment(
type: AttachmentInfo['type'],
name: string,
extra: Partial<AttachmentInfo> = {},
): AttachmentInfo {
return {
id: `att_${name}`,
name,
type,
size: 1024,
...extra,
} as AttachmentInfo;
}
beforeEach(() => {
// 重置 agent-store 到最小状态(含 currentSessionId 供 P3-10 落库)
useAgentStore.setState({
currentSessionId: 's_test',
messages: [],
isStreaming: false,
configLoaded: true,
toolsReady: true,
agentStatus: 'idle',
currentIteration: 0,
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
traceSteps: [],
currentRunId: null,
sessionRunStates: {},
modelVisionCaps: null,
} as never);
});
describe('UserMessage — 内容渲染', () => {
it('渲染用户消息文本', () => {
render(<UserMessage message={makeMessage({ content: '你好,世界' })} />);
expect(screen.getByText('你好,世界')).toBeInTheDocument();
});
it('渲染用户头像图标', () => {
const { container } = render(<UserMessage message={makeMessage()} />);
expect(container.querySelector('svg')).not.toBeNull();
});
it('空内容时只显示时间戳', () => {
const { container } = render(<UserMessage message={makeMessage({ content: '' })} />);
expect(container.textContent).not.toContain('帮我看看');
});
});
describe('UserMessage — 附件预览', () => {
it('图片附件渲染缩略图', () => {
const attachments = [
makeAttachment('image', 'photo.png', { preview: 'data:image/png;base64,AAA' }),
];
render(<UserMessage message={makeMessage({ attachments })} />);
const img = document.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('src')).toContain('data:image/png');
});
it('文本附件渲染文件名与大小', () => {
const attachments = [makeAttachment('text', 'readme.md', { textContent: '# 标题' })];
render(<UserMessage message={makeMessage({ attachments })} />);
expect(screen.getByText('readme.md')).toBeInTheDocument();
});
it('其他文件渲染通用卡片', () => {
const attachments = [makeAttachment('other', 'archive.zip')];
render(<UserMessage message={makeMessage({ attachments })} />);
expect(screen.getByText('archive.zip')).toBeInTheDocument();
});
});
describe('UserMessage — 编辑与保存(P3-10', () => {
it('双击进入编辑态并显示按钮', () => {
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
expect(screen.getByText('仅保存')).toBeInTheDocument();
expect(screen.getByText('保存并重发')).toBeInTheDocument();
});
it('仅保存:IPC 落库成功后更新 store 并退出编辑', async () => {
const updateMessageContent = vi.fn().mockResolvedValue({ success: true });
(
window.metona as unknown as {
sessions: { updateMessageContent: typeof updateMessageContent };
}
).sessions.updateMessageContent = updateMessageContent;
// store 预置该消息(updateMessage 才能命中)
useAgentStore.setState({ messages: [makeMessage()] } as never);
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '修改后的内容' } });
fireEvent.click(screen.getByText('仅保存'));
// 等待异步 IPC + store 更新
await vi.waitFor(() => {
expect(updateMessageContent).toHaveBeenCalledWith('s_test', 'm1', '修改后的内容');
});
// store 已更新
expect(useAgentStore.getState().messages.find((m) => m.id === 'm1')?.content).toBe(
'修改后的内容',
);
});
it('仅保存:内容未变时直接退出编辑不调 IPC', async () => {
const updateMessageContent = vi.fn();
(
window.metona as unknown as {
sessions: { updateMessageContent: typeof updateMessageContent };
}
).sessions.updateMessageContent = updateMessageContent;
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
fireEvent.click(screen.getByText('仅保存'));
expect(updateMessageContent).not.toHaveBeenCalled();
});
it('仅保存:IPC 失败保留编辑态并显示错误 toast', async () => {
const updateMessageContent = vi.fn().mockResolvedValue({ success: false, error: 'db error' });
(
window.metona as unknown as {
sessions: { updateMessageContent: typeof updateMessageContent };
}
).sessions.updateMessageContent = updateMessageContent;
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '新内容' } });
fireEvent.click(screen.getByText('仅保存'));
await vi.waitFor(() => {
// 编辑态保留(保存按钮仍在)
expect(screen.getByText('仅保存')).toBeInTheDocument();
});
});
it('保存并重发调用 editAndResend', () => {
const editAndResend = vi.fn();
// 注入 store action
useAgentStore.setState({ editAndResend: editAndResend } as never);
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '重发内容' } });
fireEvent.click(screen.getByText('保存并重发'));
expect(editAndResend).toHaveBeenCalledWith('m1', '重发内容');
});
it('Escape 取消编辑恢复原文', () => {
render(<UserMessage message={makeMessage()} />);
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '改动' } });
fireEvent.keyDown(textarea, { key: 'Escape' });
expect(screen.queryByText('仅保存')).not.toBeInTheDocument();
});
});