From d11748d61ac66f1d3e4dc5bcdd76847cad04d3cc 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: Tue, 9 Jun 2026 16:38:50 +0800 Subject: [PATCH] fix(desktop): make memory files preview-first (#533) Tested: cd desktop && bun run test src/__tests__/memorySettings.test.tsx Tested: bun run check:desktop Scope-risk: narrow Confidence: high --- desktop/src/__tests__/memorySettings.test.tsx | 121 +++++++++++++++-- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/MemorySettings.tsx | 128 ++++++++++++++---- desktop/src/stores/memoryStore.ts | 6 +- 8 files changed, 226 insertions(+), 39 deletions(-) diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index ff2f0be4..0474595b 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -124,7 +124,7 @@ describe('MemorySettings', () => { }) }) - it('loads project-scoped markdown memory and saves manual edits', async () => { + it('opens memory files in preview mode, edits on demand, and returns to preview after save', async () => { render() expect(await screen.findByText('Project Memory')).toBeInTheDocument() @@ -136,6 +136,11 @@ describe('MemorySettings', () => { expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument() + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory') + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + const editor = await screen.findByLabelText('Editor') expect(editor).toHaveValue('# Project Memory\n') @@ -143,7 +148,7 @@ describe('MemorySettings', () => { target: { value: '# Project Memory\n\n- Prefer small diffs.\n' }, }) expect(screen.getByText('Unsaved')).toBeInTheDocument() - expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs') + expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /save/i })) @@ -154,6 +159,101 @@ describe('MemorySettings', () => { content: '# Project Memory\n\n- Prefer small diffs.\n', }) }) + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs') + }) + + it('cancels edit mode by discarding the unsaved draft', async () => { + render() + + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory') + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + + const editor = await screen.findByLabelText('Editor') + fireEvent.change(editor, { + target: { value: '# Changed Memory\n' }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(memoryApiMock.saveFile).not.toHaveBeenCalled() + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory') + expect(screen.queryByText('Changed Memory')).not.toBeInTheDocument() + }) + + it('keeps unsaved edits when file switching is not confirmed', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + memoryApiMock.listFiles.mockResolvedValue({ + files: [ + { + path: 'MEMORY.md', + name: 'MEMORY.md', + title: 'MEMORY.md', + bytes: 18, + updatedAt: '2026-05-01T00:00:00.000Z', + type: 'project', + description: 'Project conventions.', + isIndex: true, + }, + { + path: 'notes/manual.md', + name: 'manual.md', + title: 'Manual', + bytes: 42, + updatedAt: '2026-05-01T00:02:00.000Z', + type: 'guidance', + description: 'Operator workflow.', + isIndex: false, + }, + ], + }) + memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({ + file: { + path, + content: path === 'notes/manual.md' ? '# Manual\n' : '# Project Memory\n', + updatedAt: '2026-05-01T00:00:00.000Z', + bytes: 18, + }, + })) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })) + const editor = await screen.findByLabelText('Editor') + fireEvent.change(editor, { + target: { value: '# Unsaved Memory\n' }, + }) + + fireEvent.click(screen.getByText('Manual')) + + expect(confirmSpy).toHaveBeenCalled() + expect(memoryApiMock.readFile).not.toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md') + expect(screen.getByLabelText('Editor')).toHaveValue('# Unsaved Memory\n') + + confirmSpy.mockRestore() + }) + + it('saves with the platform shortcut while editing and returns to preview mode', async () => { + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })) + const editor = await screen.findByLabelText('Editor') + fireEvent.change(editor, { + target: { value: '# Project Memory\n\n- Shortcut save.\n' }, + }) + + fireEvent.keyDown(document, { key: 's', metaKey: true }) + + await waitFor(() => { + expect(memoryApiMock.saveFile).toHaveBeenCalledWith({ + projectId: '-workspace-demo', + path: 'MEMORY.md', + content: '# Project Memory\n\n- Shortcut save.\n', + }) + }) + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Shortcut save') }) it('filters the unified resource tree by project path', async () => { @@ -257,7 +357,8 @@ describe('MemorySettings', () => { await waitFor(() => { expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md') }) - expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n') + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual') + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() }) it('opens linked memory markdown files from the rendered preview', async () => { @@ -302,7 +403,8 @@ describe('MemorySettings', () => { await waitFor(() => { expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md') }) - expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n') + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual') + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() }) it('keeps frontmatter editable but removes it from the rendered preview', async () => { @@ -317,10 +419,13 @@ describe('MemorySettings', () => { render() + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory') + expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project') + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + const editor = await screen.findByLabelText('Editor') expect(editor).toHaveValue('---\ntype: project\n---\n\n# Project Memory\n') - expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Project Memory') - expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project') }) it('opens the exact memory file requested from chat', async () => { @@ -373,8 +478,8 @@ describe('MemorySettings', () => { render() - const editor = await screen.findByLabelText('Editor') - expect(editor).toHaveValue('# Preferences\n') + expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Preferences') + expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument() expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md') expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other') expect(useUIStore.getState().pendingMemoryPath).toBeNull() diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9bf7e193..d1c070ba 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -704,6 +704,7 @@ export const en = { 'settings.memory.files': 'Memory files', 'settings.memory.resourceManager': 'Resource manager', 'settings.memory.editor': 'Editor', + 'settings.memory.edit': 'Edit', 'settings.memory.preview': 'Preview', 'settings.memory.rendered': 'Rendered', 'settings.memory.emptyProjects': 'No memory projects found.', @@ -718,6 +719,7 @@ export const en = { 'settings.memory.unsaved': 'Unsaved', 'settings.memory.saved': 'Saved', 'settings.memory.revert': 'Revert', + 'settings.memory.discardUnsavedConfirm': 'Discard unsaved memory changes?', 'settings.memory.projectSearchPlaceholder': 'Search projects by path...', 'settings.memory.fileSearchPlaceholder': 'Search memory files...', 'settings.memory.resourceSearchPlaceholder': 'Search projects or memory files...', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 7feafc87..ce927824 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -706,6 +706,7 @@ export const jp: Record = { 'settings.memory.files': 'メモリファイル', 'settings.memory.resourceManager': 'リソースマネージャー', 'settings.memory.editor': 'エディター', + 'settings.memory.edit': '編集', 'settings.memory.preview': 'プレビュー', 'settings.memory.rendered': 'レンダリング後', 'settings.memory.emptyProjects': 'メモリのあるプロジェクトが見つかりません。', @@ -720,6 +721,7 @@ export const jp: Record = { 'settings.memory.unsaved': '未保存', 'settings.memory.saved': '保存済み', 'settings.memory.revert': '元に戻す', + 'settings.memory.discardUnsavedConfirm': '未保存のメモリ変更を破棄しますか?', 'settings.memory.projectSearchPlaceholder': 'パスでプロジェクトを検索...', 'settings.memory.fileSearchPlaceholder': 'メモリファイルを検索...', 'settings.memory.resourceSearchPlaceholder': 'プロジェクトまたはメモリファイルを検索...', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 519f2b7d..78dafd16 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -706,6 +706,7 @@ export const kr: Record = { 'settings.memory.files': '메모리 파일', 'settings.memory.resourceManager': '리소스 관리자', 'settings.memory.editor': '편집기', + 'settings.memory.edit': '편집', 'settings.memory.preview': '미리 보기', 'settings.memory.rendered': '렌더링됨', 'settings.memory.emptyProjects': '메모리가 있는 프로젝트를 찾을 수 없습니다.', @@ -720,6 +721,7 @@ export const kr: Record = { 'settings.memory.unsaved': '저장 안 됨', 'settings.memory.saved': '저장됨', 'settings.memory.revert': '되돌리기', + 'settings.memory.discardUnsavedConfirm': '저장하지 않은 메모리 변경을 버릴까요?', 'settings.memory.projectSearchPlaceholder': '경로로 프로젝트 검색...', 'settings.memory.fileSearchPlaceholder': '메모리 파일 검색...', 'settings.memory.resourceSearchPlaceholder': '프로젝트 또는 메모리 파일 검색...', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 58ffe3bd..385e172a 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -706,6 +706,7 @@ export const zh: Record = { 'settings.memory.files': '記憶檔案', 'settings.memory.resourceManager': '資源管理器', 'settings.memory.editor': '編輯', + 'settings.memory.edit': '編輯', 'settings.memory.preview': '預覽', 'settings.memory.rendered': '已渲染', 'settings.memory.emptyProjects': '還沒有找到記憶專案。', @@ -720,6 +721,7 @@ export const zh: Record = { 'settings.memory.unsaved': '未儲存', 'settings.memory.saved': '已儲存', 'settings.memory.revert': '還原', + 'settings.memory.discardUnsavedConfirm': '要放棄未儲存的記憶修改嗎?', 'settings.memory.projectSearchPlaceholder': '按路徑搜尋專案...', 'settings.memory.fileSearchPlaceholder': '搜尋記憶檔案...', 'settings.memory.resourceSearchPlaceholder': '搜尋專案或記憶檔案...', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6b5f7cd4..672a50b2 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -706,6 +706,7 @@ export const zh: Record = { 'settings.memory.files': '记忆文件', 'settings.memory.resourceManager': '资源管理器', 'settings.memory.editor': '编辑', + 'settings.memory.edit': '编辑', 'settings.memory.preview': '预览', 'settings.memory.rendered': '已渲染', 'settings.memory.emptyProjects': '还没有找到记忆项目。', @@ -720,6 +721,7 @@ export const zh: Record = { 'settings.memory.unsaved': '未保存', 'settings.memory.saved': '已保存', 'settings.memory.revert': '还原', + 'settings.memory.discardUnsavedConfirm': '要放弃未保存的记忆修改吗?', 'settings.memory.projectSearchPlaceholder': '按路径搜索项目...', 'settings.memory.fileSearchPlaceholder': '搜索记忆文件...', 'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index d09b4b3f..65e3599e 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react' +import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, PencilLine, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react' import { Button } from '../components/shared/Button' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' import { useTranslation } from '../i18n' @@ -40,6 +40,7 @@ export function MemorySettings() { const [resourceQuery, setResourceQuery] = useState('') const [expandedProjectId, setExpandedProjectId] = useState(null) const [collapsedFolders, setCollapsedFolders] = useState>(new Set()) + const [isEditing, setIsEditing] = useState(false) const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId), @@ -61,6 +62,7 @@ export function MemorySettings() { [filteredFiles], ) const previewContent = stripMarkdownFrontmatter(draftContent) + const selectedFilePath = selectedFile?.path ?? null useEffect(() => { void fetchProjects(activeCwd) @@ -76,6 +78,10 @@ export function MemorySettings() { setExpandedProjectId(selectedProjectId) }, [selectedProjectId]) + useEffect(() => { + setIsEditing(false) + }, [selectedFilePath]) + useEffect(() => { if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return if (pendingMemoryPath) return @@ -115,7 +121,13 @@ export function MemorySettings() { setPendingMemoryPath, ]) + const canLeaveDirtyEdit = () => { + if (!isEditing || !isDirty) return true + return window.confirm(t('settings.memory.discardUnsavedConfirm')) + } + const handleRefresh = () => { + if (!canLeaveDirtyEdit()) return void fetchProjects(activeCwd) if (selectedProjectId) { void fetchFiles(selectedProjectId) @@ -127,6 +139,7 @@ export function MemorySettings() { setExpandedProjectId(null) return } + if (projectId !== selectedProjectId && !canLeaveDirtyEdit()) return setExpandedProjectId(projectId) if (projectId !== selectedProjectId) { selectProject(projectId) @@ -135,9 +148,33 @@ export function MemorySettings() { const handleFileOpen = (file: MemoryFile) => { if (!selectedProjectId || file.path === selectedFile?.path) return + if (!canLeaveDirtyEdit()) return void openFile(selectedProjectId, file.path) } + const handleSave = async () => { + if (!selectedFile) return + if (!isDirty) { + setIsEditing(false) + return + } + const saved = await saveFile() + if (saved) { + setIsEditing(false) + } + } + + useEffect(() => { + if (!isEditing || !selectedFile) return + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return + event.preventDefault() + void handleSave() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }) + const handlePreviewLinkClick = (href: string): boolean => { if (!selectedProjectId || !selectedFile) return false const targetPath = resolveMarkdownMemoryLink( @@ -147,6 +184,7 @@ export function MemorySettings() { files, ) if (!targetPath || targetPath === selectedFile.path) return false + if (!canLeaveDirtyEdit()) return true void openFile(selectedProjectId, targetPath) return true } @@ -165,6 +203,13 @@ export function MemorySettings() { const forceExpandFiles = Boolean(resourceQuery.trim()) + const handleCancelEdit = () => { + if (selectedFile) { + updateDraft(selectedFile.content) + } + setIsEditing(false) + } + return (
@@ -199,26 +244,6 @@ export function MemorySettings() { > {t('settings.memory.refresh')} - -
@@ -306,11 +331,44 @@ export function MemorySettings() { {selectedFile ? ( -
-
+ isEditing ? ( +
- {t('settings.memory.editor')} - MARKDOWN +
+ {t('settings.memory.editor')} + MARKDOWN +
+
+ + + +