diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index b243fb86..ab323c9c 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -174,6 +174,125 @@ describe('MessageList nested tool calls', () => { expect(useTabStore.getState().activeTabId).toBe('__settings__') }) + it('promotes memory file writes from tool calls into a dedicated memory card', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'tool-write-memory', + type: 'tool_use', + toolName: 'Write', + toolUseId: 'write-memory', + input: { + file_path: '/Users/test/.claude/projects/example/memory/preferences.md', + content: '# Preferences\n', + }, + timestamp: 1, + }, + { + id: 'result-write-memory', + type: 'tool_result', + toolUseId: 'write-memory', + content: 'File written successfully', + isError: false, + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy() + expect(screen.getByText('preferences.md')).toBeTruthy() + expect(screen.getByText('Tool details')).toBeTruthy() + }) + + it('promotes memory file reads into collapsible memory references', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'tool-read-memory-1', + type: 'tool_use', + toolName: 'Read', + toolUseId: 'read-memory-1', + input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' }, + timestamp: 1, + }, + { + id: 'result-read-memory-1', + type: 'tool_result', + toolUseId: 'read-memory-1', + content: '1 # Project Memory\n2\n3 billing ledger rules', + isError: false, + timestamp: 2, + }, + { + id: 'tool-read-memory-2', + type: 'tool_use', + toolName: 'Read', + toolUseId: 'read-memory-2', + input: { file_path: '/Users/test/.claude/projects/example/memory/workflow.md' }, + timestamp: 3, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByText('2 memory reference(s)')).toBeTruthy() + fireEvent.click(screen.getByText('2 memory reference(s)')) + expect(screen.getByText('MEMORY.md')).toBeTruthy() + expect(screen.getByText('workflow.md')).toBeTruthy() + }) + + it('keeps non-memory tools visible when a tool group also touches memory files', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'tool-read-memory', + type: 'tool_use', + toolName: 'Read', + toolUseId: 'read-memory', + input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' }, + timestamp: 1, + }, + { + id: 'tool-bash', + type: 'tool_use', + toolName: 'Bash', + toolUseId: 'bash-1', + input: { command: 'bun test' }, + timestamp: 2, + }, + { + id: 'result-bash', + type: 'tool_result', + toolUseId: 'bash-1', + content: 'ok', + isError: false, + timestamp: 3, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByText('1 memory reference(s)')).toBeTruthy() + expect(screen.getByText('Bash')).toBeTruthy() + expect(screen.getByText('bun test')).toBeTruthy() + }) + it('keeps root tool runs split when nested child tool calls appear between them', () => { const messages: UIMessage[] = [ { diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index aa2d0b3f..9239b1a4 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -1,14 +1,31 @@ import { useEffect, useState } from 'react' +import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react' import { ToolCallBlock } from './ToolCallBlock' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { Modal } from '../shared/Modal' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { AGENT_LIFECYCLE_TYPES } from '../../types/team' type ToolCall = Extract type ToolResult = Extract +type MemoryToolAction = 'saved' | 'referenced' + +type MemoryToolFile = { + path: string + label: string + action: MemoryToolAction + lineHint?: string + preview?: string +} + +type MemoryToolActivity = { + action: MemoryToolAction + files: MemoryToolFile[] +} type Props = { toolCalls: ToolCall[] @@ -59,6 +76,59 @@ export function ToolCallGroup({ childToolCallsByParent, agentTaskNotifications, isStreaming, +}: Props) { + const memoryActivity = getMemoryToolActivity(toolCalls, resultMap) + if (memoryActivity) { + const memoryToolCalls = toolCalls.filter(isMemoryToolCall) + const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall)) + if (regularToolCalls.length > 0) { + return ( +
+ + +
+ ) + } + return ( + + ) + } + + return ( + + ) +} + +function ToolCallGroupContent({ + toolCalls, + resultMap, + childToolCallsByParent, + agentTaskNotifications, + isStreaming, }: Props) { const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent') @@ -97,6 +167,123 @@ export function ToolCallGroup({ ) } +function MemoryToolActivityGroup({ + activity, + toolCalls, + resultMap, + childToolCallsByParent, + isStreaming, +}: { + activity: MemoryToolActivity + toolCalls: ToolCall[] + resultMap: Map + childToolCallsByParent: Map + isStreaming?: boolean +}) { + const [expanded, setExpanded] = useState(activity.action === 'saved') + const [detailsExpanded, setDetailsExpanded] = useState(false) + const t = useTranslation() + const titleKey = activity.action === 'saved' + ? 'chat.memorySavedFromToolsTitle' + : 'chat.memoryReferencedTitle' + const visibleFiles = activity.files.slice(0, 4) + const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length) + + useEffect(() => { + if (isStreaming) setExpanded(true) + }, [isStreaming]) + + return ( +
+
+ + + {expanded ? ( +
+
+ {visibleFiles.map((file) => ( + + ))} + {hiddenCount > 0 ? ( +
+ {t('chat.memoryMoreFiles', { count: hiddenCount })} +
+ ) : null} +
+ + + + {detailsExpanded ? ( +
+ {toolCalls.map((toolCall) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+
+ ) +} + function AgentToolGroup({ toolCalls, resultMap, @@ -442,6 +629,108 @@ function ToolCallTree({ ) } +function openMemorySettings(path?: string) { + const ui = useUIStore.getState() + if (path) ui.setPendingMemoryPath(path) + ui.setPendingSettingsTab('memory') + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') +} + +function getMemoryToolActivity( + toolCalls: ToolCall[], + resultMap: Map, +): MemoryToolActivity | null { + const filesByPath = new Map() + let sawSave = false + + for (const toolCall of toolCalls) { + const path = getToolFilePath(toolCall.input) + if (!path || !isMemoryMarkdownPath(path)) continue + + const isSave = isMemoryWriteTool(toolCall.toolName) + const isReference = toolCall.toolName === 'Read' + if (!isSave && !isReference) continue + sawSave ||= isSave + + const result = resultMap.get(toolCall.toolUseId) + const preview = extractMemoryPreview(result?.content) + const current = filesByPath.get(path) + filesByPath.set(path, { + path, + label: memoryFileLabel(path), + action: isSave ? 'saved' : (current?.action ?? 'referenced'), + lineHint: preview.lineHint || current?.lineHint, + preview: preview.text || current?.preview, + }) + } + + if (filesByPath.size === 0) return null + return { + action: sawSave ? 'saved' : 'referenced', + files: [...filesByPath.values()], + } +} + +function isMemoryToolCall(toolCall: ToolCall): boolean { + const path = getToolFilePath(toolCall.input) + if (!path || !isMemoryMarkdownPath(path)) return false + return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName) +} + +function isMemoryWriteTool(toolName: string): boolean { + return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit' +} + +function getToolFilePath(input: unknown): string | null { + if (!input || typeof input !== 'object') return null + const record = input as Record + const filePath = record.file_path ?? record.path + return typeof filePath === 'string' ? filePath : null +} + +function isMemoryMarkdownPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/') + return normalized.endsWith('.md') && normalized.includes('/memory/') +} + +function memoryFileLabel(path: string): string { + const normalized = path.replace(/\\/g, '/') + return normalized.split('/').pop() || normalized +} + +function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } { + const raw = extractTextContent(content) + if (!raw) return {} + const lineHint = extractLineHint(raw) + const lines = raw + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .split(/\r?\n/) + .map((line) => line.replace(/^\s*\d+\s*/, '').trim()) + .filter(Boolean) + + let inFrontmatter = false + for (const line of lines) { + if (line === '---') { + inFrontmatter = !inFrontmatter + continue + } + if (inFrontmatter) continue + const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim() + if (!normalized || normalized === '---') continue + if (/^(file|lines?|total)\b/i.test(normalized)) continue + return { + text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized, + lineHint, + } + } + return { lineHint } +} + +function extractLineHint(text: string): string | undefined { + const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/) + return match?.[1] ? `${match[1]} lines` : undefined +} + type AgentStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped' type AgentTaskStatus = AgentTaskNotification['status'] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bcac534c..f765cf87 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -555,6 +555,8 @@ export const en = { 'settings.memory.selectProject': 'Select a project.', 'settings.memory.noFileSelected': 'No file selected', 'settings.memory.current': 'Current', + 'settings.memory.indexFile': 'Index', + 'settings.memory.missing': 'Missing', 'settings.memory.fileCount': '{count} files', 'settings.memory.unsaved': 'Unsaved', 'settings.memory.saved': 'Saved', @@ -943,8 +945,11 @@ export const en = { 'chat.stopTitle': 'Stop generation (Cmd+.)', 'chat.jumpToLatest': 'Latest', 'chat.memorySavedTitle': 'Saved {count} memory file(s)', + 'chat.memorySavedFromToolsTitle': 'Saved {count} memory item(s)', + 'chat.memoryReferencedTitle': '{count} memory reference(s)', 'chat.memoryOpenSettings': 'Open Memory', 'chat.memoryMoreFiles': '+{count} more', + 'chat.memoryTechnicalDetails': 'Tool details', 'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.', 'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.', 'chat.turnChangesTitle': '{count} files changed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 4108c6dd..6bdafffe 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -557,6 +557,8 @@ export const zh: Record = { 'settings.memory.selectProject': '选择一个项目。', 'settings.memory.noFileSelected': '未选择文件', 'settings.memory.current': '当前', + 'settings.memory.indexFile': '索引', + 'settings.memory.missing': '目录缺失', 'settings.memory.fileCount': '{count} 个文件', 'settings.memory.unsaved': '未保存', 'settings.memory.saved': '已保存', @@ -945,8 +947,11 @@ export const zh: Record = { 'chat.stopTitle': '停止生成 (Cmd+.)', 'chat.jumpToLatest': '回到最新', 'chat.memorySavedTitle': '已保存 {count} 个记忆文件', + 'chat.memorySavedFromToolsTitle': '保存了 {count} 条记忆', + 'chat.memoryReferencedTitle': '{count} 条记忆引用', 'chat.memoryOpenSettings': '打开记忆', 'chat.memoryMoreFiles': '还有 {count} 个', + 'chat.memoryTechnicalDetails': '工具详情', 'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。', 'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。', 'chat.turnChangesTitle': '{count} 个文件已更改', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index 9a5fa951..36513f29 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' -import { FileText, Search, X } from 'lucide-react' +import type { ReactNode } from 'react' +import { BookOpenText, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react' import { Button } from '../components/shared/Button' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' import { useTranslation } from '../i18n' @@ -128,15 +129,21 @@ export function MemorySettings() { } return ( -
-
-
-

- {t('settings.memory.title')} -

-

- {t('settings.memory.description')} -

+
+
+
+
+ + +

+ {t('settings.memory.title')} +

+
+
+ {activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'} + {t('settings.memory.fileCount', { count: files.length })} +
@@ -156,14 +163,15 @@ export function MemorySettings() {
)} -
-
-
+
+
+ -
-
+
+

@@ -246,6 +253,7 @@ export function MemorySettings() { size="sm" disabled={!selectedFile || !isDirty || isSaving} onClick={() => selectedFile && updateDraft(selectedFile.content)} + icon={

{selectedFile ? ( -
+
-
+
{t('settings.memory.editor')} {formatBytes(selectedFile.bytes)}
@@ -274,11 +282,11 @@ export function MemorySettings() { value={draftContent} onChange={(event) => updateDraft(event.target.value)} spellCheck={false} - className="h-[calc(100%-36px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" + className="h-[calc(100%-40px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
-
+
{t('settings.memory.preview')} {selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}
@@ -289,7 +297,7 @@ export function MemorySettings() {
) : (
- + } text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
)}
@@ -323,7 +331,7 @@ function SearchField({ value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} - className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]" + className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]" /> {value ? ( ) @@ -391,11 +403,12 @@ function FileRow({ active: boolean onSelect: () => void }) { + const t = useTranslation() return (
-

{file.path}

+

{file.path}

{file.description && (

{file.description}

)} +
+ {formatBytes(file.bytes)} + {file.updatedAt ? {formatDate(file.updatedAt)} : null} +
) } @@ -426,10 +446,15 @@ function Badge({ children }: { children: string }) { ) } -function EmptyState({ text }: { text: string }) { +function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) { return ( -
- {text} +
+ {icon ? ( + + {icon} + + ) : null} + {text}
) }