diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx new file mode 100644 index 00000000..cca38d9e --- /dev/null +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { MemorySettings } from '../pages/MemorySettings' +import { useMemoryStore } from '../stores/memoryStore' +import { useSessionStore } from '../stores/sessionStore' +import { useSettingsStore } from '../stores/settingsStore' +import { useUIStore } from '../stores/uiStore' + +const { memoryApiMock } = vi.hoisted(() => ({ + memoryApiMock: { + listProjects: vi.fn(), + listFiles: vi.fn(), + readFile: vi.fn(), + saveFile: vi.fn(), + }, +})) + +vi.mock('../api/memory', () => ({ + memoryApi: memoryApiMock, +})) + +vi.mock('../components/markdown/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +describe('MemorySettings', () => { + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Active session', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/demo', + workDir: '/workspace/demo', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + }) + useMemoryStore.setState({ + projects: [], + files: [], + selectedProjectId: null, + selectedFile: null, + draftContent: '', + isLoadingProjects: false, + isLoadingFiles: false, + isLoadingFile: false, + isSaving: false, + error: null, + lastSavedAt: null, + }) + useUIStore.setState({ pendingMemoryPath: null, pendingSettingsTab: null }) + + memoryApiMock.listProjects.mockResolvedValue({ + projects: [ + { + id: '-workspace-demo', + label: '/workspace/demo', + memoryDir: '/tmp/claude/projects/-workspace-demo/memory', + exists: true, + fileCount: 1, + isCurrent: true, + }, + ], + }) + 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, + }, + ], + }) + memoryApiMock.readFile.mockResolvedValue({ + file: { + path: 'MEMORY.md', + content: '# Project Memory\n', + updatedAt: '2026-05-01T00:00:00.000Z', + bytes: 18, + }, + }) + memoryApiMock.saveFile.mockResolvedValue({ + ok: true, + file: { + path: 'MEMORY.md', + updatedAt: '2026-05-01T00:01:00.000Z', + bytes: 28, + }, + }) + }) + + it('loads project-scoped markdown memory and saves manual edits', async () => { + render() + + 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() + + const editor = await screen.findByLabelText('Editor') + expect(editor).toHaveValue('# Project Memory\n') + + fireEvent.change(editor, { + target: { value: '# Project Memory\n\n- Prefer small diffs.\n' }, + }) + expect(screen.getByText('Unsaved')).toBeInTheDocument() + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs') + + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(memoryApiMock.saveFile).toHaveBeenCalledWith({ + projectId: '-workspace-demo', + path: 'MEMORY.md', + content: '# Project Memory\n\n- Prefer small diffs.\n', + }) + }) + }) + + 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() + + 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 })) + + await waitFor(() => { + expect(memoryApiMock.saveFile).toHaveBeenCalledWith({ + projectId: '-workspace-demo', + path: 'notes/team.md', + content: expect.stringContaining('type: project'), + }) + }) + }) + + it('opens the exact memory file requested from chat', async () => { + memoryApiMock.listProjects.mockResolvedValue({ + projects: [ + { + id: '-workspace-demo', + label: '/workspace/demo', + memoryDir: '/tmp/claude/projects/-workspace-demo/memory', + exists: true, + fileCount: 0, + isCurrent: true, + }, + { + id: '-workspace-other', + label: '/workspace/other', + memoryDir: '/tmp/claude/projects/-workspace-other/memory', + exists: true, + fileCount: 1, + isCurrent: false, + }, + ], + }) + memoryApiMock.listFiles.mockImplementation((projectId: string) => Promise.resolve({ + files: projectId === '-workspace-other' + ? [ + { + path: 'preferences.md', + name: 'preferences.md', + title: 'preferences.md', + bytes: 24, + updatedAt: '2026-05-01T00:00:00.000Z', + type: 'preference', + isIndex: false, + }, + ] + : [], + })) + memoryApiMock.readFile.mockResolvedValue({ + file: { + path: 'preferences.md', + content: '# Preferences\n', + updatedAt: '2026-05-01T00:00:00.000Z', + bytes: 24, + }, + }) + useUIStore.setState({ + pendingMemoryPath: '/tmp/claude/projects/-workspace-other/memory/preferences.md', + }) + + render() + + const editor = await screen.findByLabelText('Editor') + expect(editor).toHaveValue('# Preferences\n') + expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md') + expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other') + expect(useUIStore.getState().pendingMemoryPath).toBeNull() + }) +}) diff --git a/desktop/src/api/memory.ts b/desktop/src/api/memory.ts new file mode 100644 index 00000000..92147589 --- /dev/null +++ b/desktop/src/api/memory.ts @@ -0,0 +1,23 @@ +import { api } from './client' +import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory' + +export const memoryApi = { + listProjects: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ projects: MemoryProject[] }>(`/api/memory/projects${query}`) + }, + + listFiles: (projectId: string) => { + const query = new URLSearchParams({ projectId }) + return api.get<{ files: MemoryFile[] }>(`/api/memory/files?${query.toString()}`) + }, + + readFile: (projectId: string, path: string) => { + const query = new URLSearchParams({ projectId, path }) + return api.get<{ file: MemoryFileDetail }>(`/api/memory/file?${query.toString()}`) + }, + + saveFile: (input: { projectId: string; path: string; content: string }) => + api.put<{ ok: true; file: Omit }>('/api/memory/file', input), +} + diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index ef0aa179..5a8baf07 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -331,6 +331,7 @@ function UsageTab({ } type ContextCategory = SessionContextSnapshot['categories'][number] +type ContextMemoryFile = SessionContextSnapshot['memoryFiles'][number] function isCapacityCategory(category: ContextCategory) { const name = category.name.toLowerCase() @@ -405,6 +406,75 @@ function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: Contex ) } +function memoryContextFileLabel(path: string) { + const normalized = path.replace(/\\/g, '/') + return normalized.split('/').pop() || normalized +} + +function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Translate }) { + const setPendingSettingsTab = useUIStore((state) => state.setPendingSettingsTab) + const setPendingMemoryPath = useUIStore((state) => state.setPendingMemoryPath) + const openSettings = (path?: string) => { + if (path) setPendingMemoryPath(path) + setPendingSettingsTab('memory') + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + } + + if (files.length === 0) { + return ( +
+
+ {t('slash.inspector.context.memoryFiles')} + +
+
{t('slash.inspector.context.noMemoryFiles')}
+
+ ) + } + + return ( +
+
+ {t('slash.inspector.context.memoryFiles')} + +
+
+ {files.map((file) => ( +
+
+
+ {memoryContextFileLabel(file.path)} +
+
+ {file.path} +
+
+
+
{file.type}
+
{formatNumber(file.tokens)} tokens
+
+
+ ))} +
+
+ ) +} + function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) { return (
@@ -509,6 +579,7 @@ function ContextTab({ return (
+
) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 36a1611b..b243fb86 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -6,6 +6,7 @@ import { sessionsApi } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' import type { UIMessage } from '../../types/chat' import type { PerSessionState } from '../../stores/chatStore' @@ -46,6 +47,7 @@ describe('MessageList nested tool calls', () => { beforeEach(() => { vi.restoreAllMocks() useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ pendingSettingsTab: null }) useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } }) vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation( @@ -139,6 +141,39 @@ describe('MessageList nested tool calls', () => { expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0) }) + it('renders saved memory events with an entrypoint to memory settings', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'memory-1', + type: 'memory_event', + event: 'saved', + files: [ + { path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' }, + ], + timestamp: 1, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByText('Saved 1 memory file(s)')).toBeTruthy() + expect(screen.getByText('preferences.md')).toBeTruthy() + + const openButton = screen.getByText('Open Memory').closest('button') + expect(openButton).toBeTruthy() + fireEvent.click(openButton!) + + expect(useUIStore.getState().pendingSettingsTab).toBe('memory') + expect(useUIStore.getState().pendingMemoryPath).toBe('/Users/test/.claude/projects/example/memory/preferences.md') + expect(useTabStore.getState().activeTabId).toBe('__settings__') + }) + it('keeps root tool runs split when nested child tool calls appear between them', () => { const messages: UIMessage[] = [ { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 914e8b27..2b2dc1a8 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,9 +1,9 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react' -import { ArrowDown } from 'lucide-react' +import { ArrowDown, BookMarked, Settings } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' -import { useTabStore } from '../../stores/tabStore' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useTeamStore } from '../../stores/teamStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' @@ -24,6 +24,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract type ToolResult = Extract +type MemoryEvent = Extract type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } @@ -241,6 +242,70 @@ function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] { return checkpoints.filter(isSessionTurnCheckpoint) } +function memoryFileLabel(path: string) { + const normalized = path.replace(/\\/g, '/') + return normalized.split('/').pop() || normalized +} + +function openMemorySettings(path?: string) { + const ui = useUIStore.getState() + if (path) ui.setPendingMemoryPath(path) + ui.setPendingSettingsTab('memory') + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') +} + +function MemoryEventCard({ message }: { message: MemoryEvent }) { + const t = useTranslation() + const visibleFiles = message.files.slice(0, 3) + const hiddenCount = Math.max(0, message.files.length - visibleFiles.length) + + return ( +
+
+
+
+
+
+
+
+ {t('chat.memorySavedTitle', { count: message.files.length })} +
+ +
+ {message.message ? ( +
{message.message}
+ ) : null} +
+ {visibleFiles.map((file) => ( + + {memoryFileLabel(file.path)} + + ))} + {hiddenCount > 0 ? ( + + {t('chat.memoryMoreFiles', { count: hiddenCount })} + + ) : null} +
+
+
+
+
+ ) +} + type MessageListProps = { sessionId?: string | null compact?: boolean @@ -742,6 +807,8 @@ export const MessageBlock = memo(function MessageBlock({ } case 'task_summary': return + case 'memory_event': + return case 'system': return (
diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 2900a66a..79c47166 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -57,8 +57,10 @@ describe('composerUtils', () => { it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) + expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' }) expect(resolveSlashUiAction('doctor')).toEqual({ type: 'settings', tab: 'diagnostics' }) expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin') + expect(mergeSlashCommands([]).map((command) => command.name)).toContain('memory') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins') }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 7a896690..78485172 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -11,6 +11,7 @@ export const PANEL_SLASH_COMMANDS = [ export const SETTINGS_SLASH_COMMANDS = [ { name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, + { name: 'memory', description: 'Open project memory files in Settings', tab: 'memory' as const }, { name: 'doctor', description: 'Open Doctor in Diagnostics', tab: 'diagnostics' as const }, ] as const @@ -31,7 +32,6 @@ export const FALLBACK_SLASH_COMMANDS = [ { name: 'config', description: 'Open configuration' }, { name: 'login', description: 'Switch Anthropic accounts' }, { name: 'logout', description: 'Sign out of current account' }, - { name: 'memory', description: 'Edit CLAUDE.md memory files' }, { name: 'model', description: 'Switch AI model' }, { name: 'permissions', description: 'View or manage tool permissions' }, { name: 'terminal-setup', description: 'Set up terminal integration' }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index e1a33b42..127be692 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -540,6 +540,30 @@ export const en = { 'settings.skills.source.mcp': 'MCP', 'settings.skills.source.bundled': 'Built-in', + // Settings > Memory + 'settings.tab.memory': 'Memory', + 'settings.memory.title': 'Project Memory', + 'settings.memory.description': 'Inspect and edit the Markdown memory files Claude writes for each project. These files are stored under ~/.claude/projects//memory/ and are loaded by the CLI runtime.', + 'settings.memory.refresh': 'Refresh', + 'settings.memory.projects': 'Projects', + '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.selectProject': 'Select a project.', + 'settings.memory.noFileSelected': 'No file selected', + 'settings.memory.current': 'Current', + 'settings.memory.fileCount': '{count} files', + '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 > Plugins 'settings.plugins.title': 'Installed Plugins', 'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.', @@ -892,6 +916,9 @@ export const en = { 'slash.inspector.context.categoryTitle': 'Estimated usage by category', 'slash.inspector.context.noCategoriesTitle': 'No context categories', 'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.', + 'slash.inspector.context.memoryFiles': 'Memory files', + 'slash.inspector.context.noMemoryFiles': 'No memory files are loaded in this session.', + 'slash.inspector.context.openMemory': 'Open Memory', 'contextIndicator.ariaLabel': 'Context usage {percent}', 'contextIndicator.pendingAria': 'Context usage not calculated', 'contextIndicator.loadingAria': 'Context usage loading', @@ -914,6 +941,9 @@ export const en = { 'chat.dismiss': 'dismiss', 'chat.stopTitle': 'Stop generation (Cmd+.)', 'chat.jumpToLatest': 'Latest', + 'chat.memorySavedTitle': 'Saved {count} memory file(s)', + 'chat.memoryOpenSettings': 'Open Memory', + 'chat.memoryMoreFiles': '+{count} more', 'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.', 'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.', 'chat.turnChangesTitle': '{count} files changed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7fc9a244..809799d1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -542,6 +542,30 @@ export const zh: Record = { 'settings.skills.source.mcp': 'MCP', 'settings.skills.source.bundled': '内置', + // Settings > Memory + 'settings.tab.memory': '记忆', + 'settings.memory.title': '项目记忆', + 'settings.memory.description': '查看和编辑 Claude 为每个项目写入的 Markdown 记忆文件。这些文件存放在 ~/.claude/projects//memory/,会被 CLI 运行时加载。', + 'settings.memory.refresh': '刷新', + 'settings.memory.projects': '项目', + '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.selectProject': '选择一个项目。', + 'settings.memory.noFileSelected': '未选择文件', + 'settings.memory.current': '当前', + 'settings.memory.fileCount': '{count} 个文件', + 'settings.memory.unsaved': '未保存', + 'settings.memory.saved': '已保存', + 'settings.memory.revert': '还原', + 'settings.memory.invalidPath': '请使用相对 .md 路径,且不能包含 .. 片段。', + 'settings.memory.fileExists': '该路径下已经有记忆文件。', + // Settings > Plugins 'settings.plugins.title': '已安装插件', 'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。', @@ -894,6 +918,9 @@ export const zh: Record = { 'slash.inspector.context.categoryTitle': '按类别估算', 'slash.inspector.context.noCategoriesTitle': '暂无上下文分类', 'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。', + 'slash.inspector.context.memoryFiles': '记忆文件', + 'slash.inspector.context.noMemoryFiles': '当前会话没有加载记忆文件。', + 'slash.inspector.context.openMemory': '打开记忆', 'contextIndicator.ariaLabel': '上下文用量 {percent}', 'contextIndicator.pendingAria': '上下文用量待计算', 'contextIndicator.loadingAria': '上下文用量加载中', @@ -916,6 +943,9 @@ export const zh: Record = { 'chat.dismiss': '关闭', 'chat.stopTitle': '停止生成 (Cmd+.)', 'chat.jumpToLatest': '回到最新', + 'chat.memorySavedTitle': '已保存 {count} 个记忆文件', + 'chat.memoryOpenSettings': '打开记忆', + 'chat.memoryMoreFiles': '还有 {count} 个', 'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。', 'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。', 'chat.turnChangesTitle': '{count} 个文件已更改', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx new file mode 100644 index 00000000..7a4cb319 --- /dev/null +++ b/desktop/src/pages/MemorySettings.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, useState } from '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' +import { useMemoryStore } from '../stores/memoryStore' +import { useSessionStore } from '../stores/sessionStore' +import { useUIStore } from '../stores/uiStore' +import type { MemoryFile, MemoryProject } from '../types/memory' + +const DEFAULT_MEMORY_PATH = 'MEMORY.md' + +export function MemorySettings() { + const t = useTranslation() + const { + projects, + files, + selectedProjectId, + selectedFile, + draftContent, + isLoadingProjects, + isLoadingFiles, + isLoadingFile, + isSaving, + error, + lastSavedAt, + fetchProjects, + selectProject, + fetchFiles, + 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(null) + + const activeSession = useMemo( + () => sessions.find((session) => session.id === activeSessionId), + [activeSessionId, sessions], + ) + const activeCwd = activeSession?.workDir || activeSession?.projectPath || undefined + const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null + const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content) + + useEffect(() => { + void fetchProjects(activeCwd) + }, [activeCwd, fetchProjects]) + + useEffect(() => { + if (!selectedProjectId) return + void fetchFiles(selectedProjectId) + }, [fetchFiles, selectedProjectId]) + + useEffect(() => { + if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return + if (pendingMemoryPath) return + const firstFile = files[0] + if (firstFile) { + void openFile(selectedProjectId, firstFile.path) + } + }, [files, isLoadingFile, isLoadingFiles, openFile, pendingMemoryPath, selectedFile, selectedProjectId]) + + useEffect(() => { + if (!pendingMemoryPath || isLoadingProjects || projects.length === 0) return + const target = resolveMemoryFileTarget(projects, pendingMemoryPath) + if (!target) { + setPendingMemoryPath(null) + return + } + if (selectedProjectId !== target.projectId) { + selectProject(target.projectId) + return + } + if (selectedFile?.path === target.path && !isLoadingFile) { + setPendingMemoryPath(null) + return + } + void openFile(target.projectId, target.path).then(() => { + setPendingMemoryPath(null) + }) + }, [ + isLoadingFile, + isLoadingProjects, + openFile, + pendingMemoryPath, + projects, + selectProject, + selectedFile?.path, + selectedProjectId, + setPendingMemoryPath, + ]) + + const handleRefresh = () => { + void fetchProjects(activeCwd) + if (selectedProjectId) { + void fetchFiles(selectedProjectId) + } + } + + const handleProjectSelect = (projectId: string) => { + if (projectId === selectedProjectId) return + selectProject(projectId) + } + + const handleFileOpen = (file: MemoryFile) => { + if (!selectedProjectId || file.path === selectedFile?.path) return + 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 ( +
+
+
+

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

+

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

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ +
+ {projects.length === 0 && !isLoadingProjects ? ( + + ) : ( + projects.map((project) => ( + handleProjectSelect(project.id)} + /> + )) + )} +
+
+ +
+ +
+
+ { + setNewPath(event.target.value) + setNewPathError(null) + }} + placeholder={t('settings.memory.newPathPlaceholder')} + className="min-w-0 flex-1" + /> + +
+ {newPathError && ( +

{newPathError}

+ )} +
+
+ {files.length === 0 && !isLoadingFiles ? ( + + ) : ( + files.map((file) => ( + handleFileOpen(file)} + /> + )) + )} +
+
+
+ +
+
+
+
+

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

+ {isDirty && {t('settings.memory.unsaved')}} + {lastSavedAt && !isDirty && {t('settings.memory.saved')}} +
+

+ {selectedProject?.memoryDir ?? t('settings.memory.selectProject')} +

+
+
+ + +
+
+ + {selectedFile ? ( +
+
+
+ {t('settings.memory.editor')} + {formatBytes(selectedFile.bytes)} +
+