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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 14:30:47 +08:00
parent df9775a6eb
commit e6554b9693
4 changed files with 265 additions and 44 deletions

View File

@ -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(<MemorySettings />)
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(<MemorySettings />)
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: [

View File

@ -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',

View File

@ -565,6 +565,12 @@ export const zh: Record<TranslationKey, string> = {
'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': '已安装插件',

View File

@ -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<string | null>(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() {
</div>
)}
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[300px_minmax(0,1fr)]">
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid min-h-0 content-start gap-4">
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<PanelHeader
title={t('settings.memory.projects')}
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
/>
<div className="max-h-[240px] overflow-y-auto p-2">
<div className="border-b border-[var(--color-border)] p-3">
<SearchField
value={projectQuery}
onChange={setProjectQuery}
placeholder={t('settings.memory.projectSearchPlaceholder')}
ariaLabel={t('settings.memory.projectSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{projects.length === 0 && !isLoadingProjects ? (
<EmptyState text={t('settings.memory.emptyProjects')} />
) : filteredProjects.length === 0 ? (
<EmptyState text={t('settings.memory.noProjectMatches')} />
) : (
projects.map((project) => (
filteredProjects.map((project) => (
<ProjectRow
key={project.id}
project={project}
@ -186,38 +214,39 @@ export function MemorySettings() {
title={t('settings.memory.files')}
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
/>
<div className="border-b border-[var(--color-border)] p-3">
<div className="flex gap-2">
<Input
value={newPath}
onChange={(event) => {
setNewPath(event.target.value)
setNewPathError(null)
}}
placeholder={t('settings.memory.newPathPlaceholder')}
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="secondary"
size="sm"
disabled={!selectedProjectId}
loading={isSaving && !selectedFile}
onClick={handleCreate}
icon={<span className="material-symbols-outlined text-[16px]">add</span>}
>
{t('settings.memory.newFile')}
</Button>
</div>
<div className="grid gap-3 border-b border-[var(--color-border)] p-3">
<CreateFileControl
value={newPath}
onChange={(value) => {
setNewPath(value)
setNewPathError(null)
}}
onCreate={handleCreate}
disabled={!selectedProjectId}
loading={isSaving && !selectedFile}
placeholder={t('settings.memory.newPathPlaceholder')}
label={t('settings.memory.createMemoryFile')}
/>
{newPathError && (
<p className="mt-2 text-xs text-[var(--color-error)]">{newPathError}</p>
)}
{files.length > 3 ? (
<SearchField
value={fileQuery}
onChange={setFileQuery}
placeholder={t('settings.memory.fileSearchPlaceholder')}
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
) : null}
</div>
<div className="max-h-[360px] overflow-y-auto p-2">
<div className="max-h-[420px] overflow-y-auto p-2">
{files.length === 0 && !isLoadingFiles ? (
<EmptyState text={t('settings.memory.emptyFiles')} />
) : filteredFiles.length === 0 ? (
<EmptyState text={t('settings.memory.noFileMatches')} />
) : (
files.map((file) => (
filteredFiles.map((file) => (
<FileRow
key={file.path}
file={file}
@ -230,11 +259,11 @@ export function MemorySettings() {
</section>
</div>
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_36px_rgba(15,23,42,0.04)]">
<div className="flex flex-col gap-3 border-b border-[var(--color-border)] p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
<h3 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
{selectedFile?.path ?? t('settings.memory.noFileSelected')}
</h3>
{isDirty && <Badge>{t('settings.memory.unsaved')}</Badge>}
@ -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)]"
/>
</div>
<div className="min-h-0 overflow-y-auto">
@ -287,8 +316,8 @@ export function MemorySettings() {
<span>{t('settings.memory.preview')}</span>
<span>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
</div>
<div className="p-4">
<MarkdownRenderer content={draftContent || ' '} variant="document" />
<div className="p-6">
<MarkdownRenderer content={previewContent || ' '} variant="document" />
</div>
</div>
</div>
@ -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 (
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<input
aria-label={ariaLabel}
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)]"
/>
{value ? (
<button
type="button"
aria-label={clearLabel}
onClick={() => onChange('')}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
)
}
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 (
<div className="grid grid-cols-[minmax(0,1fr)_44px] gap-2">
<input
value={value}
onChange={(event) => 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)]"
/>
<button
type="button"
aria-label={label}
disabled={disabled || loading}
onClick={onCreate}
className="flex h-11 w-11 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus size={18} aria-hidden="true" />
</button>
</div>
)
}
function PanelHeader({ title, meta }: { title: string; meta: string }) {
return (
<div className="flex h-12 items-center justify-between border-b border-[var(--color-border)] px-3">
@ -322,21 +434,24 @@ function ProjectRow({
onSelect: () => void
}) {
const t = useTranslation()
const display = projectDisplayName(project.label)
return (
<button
type="button"
onClick={onSelect}
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
title={project.label}
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-medium">{project.label}</span>
<span className="min-w-0 truncate text-sm font-semibold">{display}</span>
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{project.label}</p>
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
</div>
</button>
@ -356,14 +471,17 @@ function FileRow({
<button
type="button"
onClick={onSelect}
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-medium">{file.title}</span>
<span className="flex min-w-0 items-center gap-2">
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-semibold">{file.title}</span>
</span>
{file.type && <Badge>{file.type}</Badge>}
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{file.path}</p>
@ -396,6 +514,41 @@ function normalizeMemoryPath(value: string): string {
return value.trim().replace(/\\/g, '/').replace(/^\/+/, '')
}
function normalizeSearch(value: string): string {
return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
}
function filterProjects(projects: MemoryProject[], query: string): MemoryProject[] {
const normalized = normalizeSearch(query)
if (!normalized) return projects
return projects.filter((project) =>
normalizeSearch(`${project.label} ${project.memoryDir} ${project.id}`).includes(normalized),
)
}
function filterFiles(files: MemoryFile[], query: string): MemoryFile[] {
const normalized = normalizeSearch(query)
if (!normalized) return files
return files.filter((file) =>
normalizeSearch(`${file.title} ${file.path} ${file.description ?? ''} ${file.type ?? ''}`).includes(normalized),
)
}
function projectDisplayName(label: string): string {
const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
const parts = normalized.split('/').filter(Boolean)
if (parts.length >= 2) return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
return parts[0] ?? label
}
function stripMarkdownFrontmatter(content: string): string {
if (!content.startsWith('---')) return content
const end = content.indexOf('\n---', 3)
if (end < 0) return content
const after = content.indexOf('\n', end + 4)
return after < 0 ? '' : content.slice(after + 1).trimStart()
}
function normalizeFsPath(value: string): string {
return value.replace(/\\/g, '/').replace(/\/+$/, '')
}