Remove manual memory creation from settings

Memory files are produced by the agent during conversation, so the desktop settings surface should focus on browsing, search, and editing existing Markdown memory rather than offering a manual file factory.

Constraint: Project memory creation belongs to the conversation/runtime flow, not Settings.

Rejected: Keep the add box as a fallback | it implies users should manually seed runtime-owned memory and creates low-value UI clutter.

Confidence: high

Scope-risk: narrow

Directive: Do not reintroduce manual memory-file creation in Settings unless the runtime product flow explicitly changes.

Tested: cd desktop && bunx vitest run src/__tests__/memorySettings.test.tsx

Tested: cd desktop && bun run lint

Tested: bun run check:desktop

Tested: Browser smoke on local Vite/server verified the create placeholder, create action, and duplicate-path error text are absent while file search and four memory files render.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:26:02 +08:00
parent c125963346
commit 33ede2982c
4 changed files with 11 additions and 161 deletions

View File

@ -112,6 +112,8 @@ describe('MemorySettings', () => {
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
expect(await screen.findByText('/workspace/demo')).toBeInTheDocument()
expect(await screen.findByText('Project conventions.')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument()
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('# Project Memory\n')
@ -133,46 +135,6 @@ describe('MemorySettings', () => {
})
})
it('creates a new markdown memory file with a project frontmatter template', async () => {
memoryApiMock.listFiles
.mockResolvedValueOnce({ files: [] })
.mockResolvedValueOnce({
files: [
{
path: 'notes/team.md',
name: 'team.md',
title: 'team',
bytes: 75,
updatedAt: '2026-05-01T00:02:00.000Z',
type: 'project',
isIndex: false,
},
],
})
memoryApiMock.readFile.mockResolvedValueOnce({
file: {
path: 'notes/team.md',
content: '# team\n',
updatedAt: '2026-05-01T00:02:00.000Z',
bytes: 75,
},
})
render(<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: /create memory file/i }))
await waitFor(() => {
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
projectId: '-workspace-demo',
path: 'notes/team.md',
content: expect.stringContaining('type: project'),
})
})
})
it('filters projects by path so large memory lists are navigable', async () => {
memoryApiMock.listProjects.mockResolvedValue({
projects: [

View File

@ -549,11 +549,9 @@ export const en = {
'settings.memory.files': 'Memory files',
'settings.memory.editor': 'Editor',
'settings.memory.preview': 'Preview',
'settings.memory.newPathPlaceholder': 'MEMORY.md or notes/project.md',
'settings.memory.newFile': 'New',
'settings.memory.emptyProjects': 'No memory projects found.',
'settings.memory.emptyFiles': 'No memory files yet. Create MEMORY.md to start.',
'settings.memory.selectFile': 'Select or create a memory file.',
'settings.memory.emptyFiles': 'No memory files found yet.',
'settings.memory.selectFile': 'Select a memory file.',
'settings.memory.selectProject': 'Select a project.',
'settings.memory.noFileSelected': 'No file selected',
'settings.memory.current': 'Current',
@ -561,13 +559,10 @@ export const en = {
'settings.memory.unsaved': 'Unsaved',
'settings.memory.saved': 'Saved',
'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

View File

@ -551,11 +551,9 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.files': '记忆文件',
'settings.memory.editor': '编辑',
'settings.memory.preview': '预览',
'settings.memory.newPathPlaceholder': 'MEMORY.md 或 notes/project.md',
'settings.memory.newFile': '新建',
'settings.memory.emptyProjects': '还没有找到记忆项目。',
'settings.memory.emptyFiles': '还没有记忆文件。可以先创建 MEMORY.md。',
'settings.memory.selectFile': '选择或创建一个记忆文件。',
'settings.memory.emptyFiles': '还没有找到记忆文件。',
'settings.memory.selectFile': '选择一个记忆文件。',
'settings.memory.selectProject': '选择一个项目。',
'settings.memory.noFileSelected': '未选择文件',
'settings.memory.current': '当前',
@ -563,13 +561,10 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.unsaved': '未保存',
'settings.memory.saved': '已保存',
'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

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { FileText, Plus, Search, X } from 'lucide-react'
import { FileText, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
@ -31,14 +31,11 @@ export function MemorySettings() {
openFile,
updateDraft,
saveFile,
createFile,
} = useMemoryStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
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('')
@ -130,22 +127,6 @@ export function MemorySettings() {
void openFile(selectedProjectId, file.path)
}
const handleCreate = () => {
if (!selectedProjectId) return
const path = normalizeMemoryPath(newPath || DEFAULT_MEMORY_PATH)
if (!isValidMemoryPath(path)) {
setNewPathError(t('settings.memory.invalidPath'))
return
}
if (files.some((file) => file.path === path)) {
setNewPathError(t('settings.memory.fileExists'))
return
}
setNewPath('')
setNewPathError(null)
void createFile(selectedProjectId, path, buildMemoryTemplate(path))
}
return (
<div className="flex h-full min-h-[640px] flex-col gap-5">
<header className="flex flex-col gap-4 border-b border-[var(--color-border)] pb-5 sm:flex-row sm:items-end sm:justify-between">
@ -214,23 +195,8 @@ export function MemorySettings() {
title={t('settings.memory.files')}
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
/>
<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 ? (
{files.length > 3 ? (
<div className="border-b border-[var(--color-border)] p-3">
<SearchField
value={fileQuery}
onChange={setFileQuery}
@ -238,8 +204,8 @@ export function MemorySettings() {
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
) : null}
</div>
</div>
) : null}
<div className="max-h-[420px] overflow-y-auto p-2">
{files.length === 0 && !isLoadingFiles ? (
<EmptyState text={t('settings.memory.emptyFiles')} />
@ -373,48 +339,6 @@ function SearchField({
)
}
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">
@ -510,10 +434,6 @@ function EmptyState({ text }: { text: string }) {
)
}
function normalizeMemoryPath(value: string): string {
return value.trim().replace(/\\/g, '/').replace(/^\/+/, '')
}
function normalizeSearch(value: string): string {
return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
}
@ -571,28 +491,6 @@ function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string
return null
}
function isValidMemoryPath(path: string): boolean {
return (
path.length > 0 &&
path.endsWith('.md') &&
!path.includes('\0') &&
!path.split('/').some((part) => part === '' || part === '.' || part === '..')
)
}
function buildMemoryTemplate(path: string): string {
const parts = path.split('/')
const title = parts[parts.length - 1]?.replace(/\.md$/, '') || 'Memory'
return `---
type: project
description: Manually curated project memory.
---
# ${title}
`
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''