From 7a2d0444ff84dded71e15d10c5c8d74065ccc9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 20:59:53 +0800 Subject: [PATCH] Make memory activity visible in desktop workflows Desktop users could not tell when project memory was being referenced or updated unless they opened raw tool details. This surfaces memory reads and writes as a dedicated chat activity, while keeping ordinary tool calls visible in mixed groups, and tightens the memory settings layout for faster project and file navigation. Constraint: CLI memory currently arrives through system notifications and normal file tool calls rather than a dedicated memory tool. Rejected: Hide memory file writes inside the existing file tool group | users need a distinct product signal for memory activity. Rejected: Preserve the manual create-memory control | project memories are model-produced files and the user already asked to remove manual creation. Confidence: high Scope-risk: moderate Directive: Keep non-memory tool calls on the normal rendering path when adding more memory activity signals. Tested: cd desktop && bun run test -- MessageList memorySettings Tested: bun run check:desktop Tested: agent-browser screenshots for settings and chat memory activity under /tmp/cc-haha-memory-redesign-*.png --- .../src/components/chat/MessageList.test.tsx | 119 ++++++++ desktop/src/components/chat/ToolCallGroup.tsx | 289 ++++++++++++++++++ desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + desktop/src/pages/MemorySettings.tsx | 131 ++++---- 5 files changed, 496 insertions(+), 53 deletions(-) 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}
) }