From e6554b96933650f337fc91e65d17cd61c6161a74 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 14:30:47 +0800 Subject: [PATCH] Make project memory manageable at desktop scale The memory settings page was technically functional but broke down once users had many project folders: paths were hard to scan, there was no project search, the create-file row clipped its own action, and YAML frontmatter dominated the preview instead of the actual memory content. This pass keeps the same storage model and API while redesigning the interaction around searchable project/file lists, compact path labels, an icon-only create action, and a cleaner edit/preview split. Constraint: Memory remains Markdown files under the existing project memory directory contract. Rejected: Add a new memory database or indexer | unnecessary for the current local project-list scale and would widen persistence risk. Confidence: high Scope-risk: narrow Directive: Keep memory settings optimized for many projects; do not remove project search without replacing it with an equivalent navigation path. Tested: cd desktop && bunx vitest run src/__tests__/memorySettings.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: agent-browser E2E for project search, auto-selecting the matched project, and creating notes/manual.md --- desktop/src/__tests__/memorySettings.test.tsx | 58 ++++- desktop/src/i18n/locales/en.ts | 6 + desktop/src/i18n/locales/zh.ts | 6 + desktop/src/pages/MemorySettings.tsx | 239 ++++++++++++++---- 4 files changed, 265 insertions(+), 44 deletions(-) diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index cca38d9e..de7619c8 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -162,7 +162,7 @@ describe('MemorySettings', () => { const pathInput = await screen.findByPlaceholderText('MEMORY.md or notes/project.md') fireEvent.change(pathInput, { target: { value: 'notes/team.md' } }) - fireEvent.click(screen.getByRole('button', { name: /new/i })) + fireEvent.click(screen.getByRole('button', { name: /create memory file/i })) await waitFor(() => { expect(memoryApiMock.saveFile).toHaveBeenCalledWith({ @@ -173,6 +173,62 @@ describe('MemorySettings', () => { }) }) + it('filters projects by path so large memory lists are navigable', async () => { + memoryApiMock.listProjects.mockResolvedValue({ + projects: [ + { + id: '-workspace-alpha', + label: '/workspace/alpha', + memoryDir: '/tmp/claude/projects/-workspace-alpha/memory', + exists: true, + fileCount: 1, + isCurrent: true, + }, + { + id: '-workspace-beta', + label: '/workspace/beta', + memoryDir: '/tmp/claude/projects/-workspace-beta/memory', + exists: true, + fileCount: 2, + isCurrent: false, + }, + ], + }) + + render() + + expect(await screen.findByText('workspace/alpha')).toBeInTheDocument() + expect(await screen.findByText('workspace/beta')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Search projects by path...'), { + target: { value: 'beta' }, + }) + + expect(screen.queryByText('workspace/alpha')).not.toBeInTheDocument() + expect(screen.getByText('workspace/beta')).toBeInTheDocument() + await waitFor(() => { + expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta') + }) + }) + + it('keeps frontmatter editable but removes it from the rendered preview', async () => { + memoryApiMock.readFile.mockResolvedValue({ + file: { + path: 'MEMORY.md', + content: '---\ntype: project\n---\n\n# Project Memory\n', + updatedAt: '2026-05-01T00:00:00.000Z', + bytes: 39, + }, + }) + + render() + + 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 () => { memoryApiMock.listProjects.mockResolvedValue({ projects: [ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 127be692..705d3c3f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -563,6 +563,12 @@ export const en = { 'settings.memory.revert': 'Revert', 'settings.memory.invalidPath': 'Use a relative .md path without .. segments.', 'settings.memory.fileExists': 'A memory file already exists at that path.', + 'settings.memory.projectSearchPlaceholder': 'Search projects by path...', + 'settings.memory.fileSearchPlaceholder': 'Search memory files...', + 'settings.memory.noProjectMatches': 'No projects match this search.', + 'settings.memory.noFileMatches': 'No memory files match this search.', + 'settings.memory.createMemoryFile': 'Create memory file', + 'settings.memory.clearSearch': 'Clear search', // Settings > Plugins 'settings.plugins.title': 'Installed Plugins', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 809799d1..43764257 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -565,6 +565,12 @@ export const zh: Record = { 'settings.memory.revert': '还原', 'settings.memory.invalidPath': '请使用相对 .md 路径,且不能包含 .. 片段。', 'settings.memory.fileExists': '该路径下已经有记忆文件。', + 'settings.memory.projectSearchPlaceholder': '按路径搜索项目...', + 'settings.memory.fileSearchPlaceholder': '搜索记忆文件...', + 'settings.memory.noProjectMatches': '没有匹配的项目。', + 'settings.memory.noFileMatches': '没有匹配的记忆文件。', + 'settings.memory.createMemoryFile': '创建记忆文件', + 'settings.memory.clearSearch': '清空搜索', // Settings > Plugins 'settings.plugins.title': '已安装插件', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index 7a4cb319..2060cf68 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' +import { FileText, Plus, Search, X } from 'lucide-react' import { Button } from '../components/shared/Button' -import { Input } from '../components/shared/Input' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' import { useTranslation } from '../i18n' import { formatBytes } from '../lib/formatBytes' @@ -39,6 +39,8 @@ export function MemorySettings() { const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath) const [newPath, setNewPath] = useState('') const [newPathError, setNewPathError] = useState(null) + const [projectQuery, setProjectQuery] = useState('') + const [fileQuery, setFileQuery] = useState('') const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId), @@ -47,6 +49,15 @@ export function MemorySettings() { const activeCwd = activeSession?.workDir || activeSession?.projectPath || undefined const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content) + const filteredProjects = useMemo( + () => filterProjects(projects, projectQuery), + [projectQuery, projects], + ) + const filteredFiles = useMemo( + () => filterFiles(files, fileQuery), + [fileQuery, files], + ) + const previewContent = stripMarkdownFrontmatter(draftContent) useEffect(() => { void fetchProjects(activeCwd) @@ -57,6 +68,12 @@ export function MemorySettings() { void fetchFiles(selectedProjectId) }, [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]) + useEffect(() => { if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return if (pendingMemoryPath) return @@ -158,18 +175,29 @@ export function MemorySettings() { )} -
+
-
+
+ +
+
{projects.length === 0 && !isLoadingProjects ? ( + ) : filteredProjects.length === 0 ? ( + ) : ( - projects.map((project) => ( + filteredProjects.map((project) => ( -
-
- { - setNewPath(event.target.value) - setNewPathError(null) - }} - placeholder={t('settings.memory.newPathPlaceholder')} - className="min-w-0 flex-1" - /> - -
+
+ { + setNewPath(value) + setNewPathError(null) + }} + onCreate={handleCreate} + disabled={!selectedProjectId} + loading={isSaving && !selectedFile} + placeholder={t('settings.memory.newPathPlaceholder')} + label={t('settings.memory.createMemoryFile')} + /> {newPathError && (

{newPathError}

)} + {files.length > 3 ? ( + + ) : null}
-
+
{files.length === 0 && !isLoadingFiles ? ( + ) : filteredFiles.length === 0 ? ( + ) : ( - files.map((file) => ( + filteredFiles.map((file) => (
-
+
-

+

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

{isDirty && {t('settings.memory.unsaved')}} @@ -279,7 +308,7 @@ 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-4 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" + 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)]" />
@@ -287,8 +316,8 @@ export function MemorySettings() { {t('settings.memory.preview')} {selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}
-
- +
+
@@ -303,6 +332,89 @@ export function MemorySettings() { ) } +function SearchField({ + value, + onChange, + placeholder, + ariaLabel, + clearLabel, +}: { + value: string + onChange: (value: string) => void + placeholder: string + ariaLabel: string + clearLabel: string +}) { + return ( +
+
+ ) +} + +function CreateFileControl({ + value, + onChange, + onCreate, + disabled, + loading, + placeholder, + label, +}: { + value: string + onChange: (value: string) => void + onCreate: () => void + disabled: boolean + loading: boolean + placeholder: string + label: string +}) { + return ( +
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onCreate() + }} + placeholder={placeholder} + aria-label={label} + className="h-11 min-w-0 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 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)]" + /> + +
+ ) +} + function PanelHeader({ title, meta }: { title: string; meta: string }) { return (
@@ -322,21 +434,24 @@ function ProjectRow({ onSelect: () => void }) { const t = useTranslation() + const display = projectDisplayName(project.label) return ( @@ -356,14 +471,17 @@ function FileRow({