From 00733838efc6a6e9e539408457287dc55db83dfb 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: Fri, 15 May 2026 09:14:03 +0800 Subject: [PATCH] Make project memory navigation feel like a file tree The memory settings surface previously split projects and memory files into separate selection regions. This change keeps the desktop settings frame but makes the memory resource area behave like a single resource tree: project folders expand in place, nested memory folders/files remain in the same hierarchy, and visual badges are removed from the tree rows to keep the UI quiet. Constraint: The desktop settings shell and existing memory store/API contracts stay unchanged. Rejected: Keep project and file lists as separate panels | the requested interaction needs project folders and their memory files in one tree. Rejected: Keep file-type badges in tree rows | they made the navigation look noisy and artificial. Confidence: high Scope-risk: moderate Directive: Keep memory navigation tree rows plain; do not reintroduce badge-heavy file rows without a UX pass. Tested: cd desktop && bun run test src/__tests__/memorySettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: local agent-browser screenshot of settings memory tree --- desktop/src/__tests__/memorySettings.test.tsx | 82 ++- desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/pages/MemorySettings.tsx | 561 +++++++++++++----- 4 files changed, 480 insertions(+), 171 deletions(-) diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index 6166ede1..eb544f27 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -110,8 +110,10 @@ describe('MemorySettings', () => { expect(await screen.findByText('Project Memory')).toBeInTheDocument() expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo') - expect(await screen.findByText('/workspace/demo')).toBeInTheDocument() - expect(await screen.findByText('Project conventions.')).toBeInTheDocument() + expect(await screen.findAllByText('workspace/demo')).not.toHaveLength(0) + expect(await screen.findAllByText('MEMORY.md')).not.toHaveLength(0) + expect(screen.queryByText('Project conventions.')).not.toBeInTheDocument() + expect(screen.queryByText('Index')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument() @@ -135,7 +137,7 @@ describe('MemorySettings', () => { }) }) - it('filters projects by path so large memory lists are navigable', async () => { + it('filters the unified resource tree by project path', async () => { memoryApiMock.listProjects.mockResolvedValue({ projects: [ { @@ -159,20 +161,86 @@ describe('MemorySettings', () => { render() - expect(await screen.findByText('workspace/alpha')).toBeInTheDocument() + expect(await screen.findAllByText('workspace/alpha')).not.toHaveLength(0) expect(await screen.findByText('workspace/beta')).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('Search projects by path...'), { + fireEvent.change(screen.getByLabelText('Search projects or memory files...'), { target: { value: 'beta' }, }) - expect(screen.queryByText('workspace/alpha')).not.toBeInTheDocument() - expect(screen.getByText('workspace/beta')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workspace\/alpha/ })).not.toBeInTheDocument() + expect(screen.getAllByText('workspace/beta').length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('button', { name: 'Toggle workspace/beta' })) await waitFor(() => { expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta') }) }) + it('renders nested memory files as a collapsible resource tree', async () => { + 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, + }, + { + path: 'notes/archive/old.md', + name: 'old.md', + title: 'Old note', + bytes: 24, + updatedAt: '2026-05-01T00:03:00.000Z', + 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() + + expect(await screen.findByRole('button', { name: 'Toggle notes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Toggle archive' })).toBeInTheDocument() + expect(screen.getByText('Manual')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle notes' })) + expect(screen.queryByText('Manual')).not.toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Search projects or memory files...'), { + target: { value: 'manual' }, + }) + + expect(screen.getByText('Manual')).toBeInTheDocument() + fireEvent.click(screen.getByText('Manual')) + + await waitFor(() => { + expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md') + }) + expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n') + }) + it('keeps frontmatter editable but removes it from the rendered preview', async () => { memoryApiMock.readFile.mockResolvedValue({ file: { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 35d040c2..d73575f6 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -556,8 +556,10 @@ export const en = { 'settings.memory.refresh': 'Refresh', 'settings.memory.projects': 'Projects', 'settings.memory.files': 'Memory files', + 'settings.memory.resourceManager': 'Resource manager', 'settings.memory.editor': 'Editor', 'settings.memory.preview': 'Preview', + 'settings.memory.rendered': 'Rendered', 'settings.memory.emptyProjects': 'No memory projects found.', 'settings.memory.emptyFiles': 'No memory files found yet.', 'settings.memory.selectFile': 'Select a memory file.', @@ -572,9 +574,11 @@ export const en = { 'settings.memory.revert': 'Revert', 'settings.memory.projectSearchPlaceholder': 'Search projects by path...', 'settings.memory.fileSearchPlaceholder': 'Search memory files...', + 'settings.memory.resourceSearchPlaceholder': 'Search projects or memory files...', 'settings.memory.noProjectMatches': 'No projects match this search.', 'settings.memory.noFileMatches': 'No memory files match this search.', 'settings.memory.clearSearch': 'Clear search', + 'settings.memory.toggleFolder': 'Toggle {name}', // Settings > Plugins 'settings.plugins.title': 'Installed Plugins', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 89d9347a..17fa42d2 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -558,8 +558,10 @@ export const zh: Record = { 'settings.memory.refresh': '刷新', 'settings.memory.projects': '项目', 'settings.memory.files': '记忆文件', + 'settings.memory.resourceManager': '资源管理器', 'settings.memory.editor': '编辑', 'settings.memory.preview': '预览', + 'settings.memory.rendered': '已渲染', 'settings.memory.emptyProjects': '还没有找到记忆项目。', 'settings.memory.emptyFiles': '还没有找到记忆文件。', 'settings.memory.selectFile': '选择一个记忆文件。', @@ -574,9 +576,11 @@ export const zh: Record = { 'settings.memory.revert': '还原', 'settings.memory.projectSearchPlaceholder': '按路径搜索项目...', 'settings.memory.fileSearchPlaceholder': '搜索记忆文件...', + 'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...', 'settings.memory.noProjectMatches': '没有匹配的项目。', 'settings.memory.noFileMatches': '没有匹配的记忆文件。', 'settings.memory.clearSearch': '清空搜索', + 'settings.memory.toggleFolder': '展开或折叠 {name}', // Settings > Plugins 'settings.plugins.title': '已安装插件', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index 36513f29..c5661f86 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, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react' +import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, 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' @@ -37,8 +37,9 @@ export function MemorySettings() { const activeSessionId = useSessionStore((s) => s.activeSessionId) const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath) const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath) - const [projectQuery, setProjectQuery] = useState('') - const [fileQuery, setFileQuery] = useState('') + const [resourceQuery, setResourceQuery] = useState('') + const [expandedProjectId, setExpandedProjectId] = useState(null) + const [collapsedFolders, setCollapsedFolders] = useState>(new Set()) const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId), @@ -48,12 +49,16 @@ export function MemorySettings() { const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content) const filteredProjects = useMemo( - () => filterProjects(projects, projectQuery), - [projectQuery, projects], + () => filterProjects(projects, resourceQuery, selectedProjectId, files), + [files, projects, resourceQuery, selectedProjectId], ) const filteredFiles = useMemo( - () => filterFiles(files, fileQuery), - [fileQuery, files], + () => filterFiles(files, resourceQuery), + [files, resourceQuery], + ) + const fileTree = useMemo( + () => buildMemoryFileTree(filteredFiles), + [filteredFiles], ) const previewContent = stripMarkdownFrontmatter(draftContent) @@ -67,10 +72,9 @@ export function MemorySettings() { }, [fetchFiles, selectedProjectId]) useEffect(() => { - if (!projectQuery.trim() || filteredProjects.length === 0) return - if (selectedProjectId && filteredProjects.some((project) => project.id === selectedProjectId)) return - selectProject(filteredProjects[0]!.id) - }, [filteredProjects, projectQuery, selectProject, selectedProjectId]) + if (!selectedProjectId) return + setExpandedProjectId(selectedProjectId) + }, [selectedProjectId]) useEffect(() => { if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return @@ -118,9 +122,15 @@ export function MemorySettings() { } } - const handleProjectSelect = (projectId: string) => { - if (projectId === selectedProjectId) return - selectProject(projectId) + const handleProjectToggle = (projectId: string) => { + if (expandedProjectId === projectId) { + setExpandedProjectId(null) + return + } + setExpandedProjectId(projectId) + if (projectId !== selectedProjectId) { + selectProject(projectId) + } } const handleFileOpen = (file: MemoryFile) => { @@ -128,55 +138,98 @@ export function MemorySettings() { void openFile(selectedProjectId, file.path) } + const toggleFolder = (path: string) => { + setCollapsedFolders((previous) => { + const next = new Set(previous) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + } + + const forceExpandFiles = Boolean(resourceQuery.trim()) + return ( -
-
-
-
- - -

+
+
+
+ + +
+

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

-
-
- {activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'} - {t('settings.memory.fileCount', { count: files.length })} +

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

+
+
+
+ +
+ + +
-
{error && ( -
+
{error}
)} -
- -
-
+
+
-

- {selectedFile?.path ?? t('settings.memory.noFileSelected')} +

+ {selectedFile?.path ? fileNameFromPath(selectedFile.path) : t('settings.memory.noFileSelected')}

{isDirty && {t('settings.memory.unsaved')}} {lastSavedAt && !isDirty && {t('settings.memory.saved')}} @@ -246,36 +282,22 @@ export function MemorySettings() { {selectedProject?.memoryDir ?? t('settings.memory.selectProject')}

-
- - +
+ {selectedFile ? ( + <> + {formatBytes(selectedFile.bytes)} + {selectedFile.updatedAt ? {formatDate(selectedFile.updatedAt)} : null} + + ) : null}
{selectedFile ? (
-
+
{t('settings.memory.editor')} - {formatBytes(selectedFile.bytes)} + MARKDOWN