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({