mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge desktop memory surface into main
This brings the desktop memory worktree onto local main after main advanced with opener, IM, zoom, and autonomous goal work. The conflict resolution keeps both the selected-chat reference imports and memory event settings entrypoint in MessageList, and keeps both open-targets and memory API routes in the server router. Constraint: main and feat/desktop-memory-surface diverged across desktop chat and server routing. Constraint: desktop Vitest runs src-tauri tests under Vitest, so the Tauri config test cannot import bun:test. Rejected: Fast-forward merge | main carried newer local commits not present on the memory branch. Confidence: high Scope-risk: moderate Directive: Keep chat selection references, memory cards, open-target routes, and memory routes together when touching these files. Tested: bun run check:desktop Tested: bun run check:server
This commit is contained in:
commit
f455065997
@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('tauri security config', () => {
|
||||
it('allows desktop sidecar image URLs for opener icons', () => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(import.meta.dir, 'tauri.conf.json'), 'utf8'),
|
||||
readFileSync(join(currentDir, 'tauri.conf.json'), 'utf8'),
|
||||
) as {
|
||||
app?: {
|
||||
security?: {
|
||||
|
||||
250
desktop/src/__tests__/memorySettings.test.tsx
Normal file
250
desktop/src/__tests__/memorySettings.test.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
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 }) => (
|
||||
<div data-testid="markdown-preview">{content}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<MemorySettings />)
|
||||
|
||||
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()
|
||||
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')
|
||||
|
||||
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('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: [
|
||||
{
|
||||
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(<MemorySettings />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
22
desktop/src/api/memory.ts
Normal file
22
desktop/src/api/memory.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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<MemoryFileDetail, 'content'> }>('/api/memory/file', input),
|
||||
}
|
||||
145
desktop/src/components/chat/LocalSlashCommandPanel.test.tsx
Normal file
145
desktop/src/components/chat/LocalSlashCommandPanel.test.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const { sessionsApiMock } = vi.hoisted(() => ({
|
||||
sessionsApiMock: {
|
||||
getInspection: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/sessions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/sessions')>()
|
||||
return {
|
||||
...actual,
|
||||
sessionsApi: {
|
||||
getInspection: sessionsApiMock.getInspection,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { LocalSlashCommandPanel } from './LocalSlashCommandPanel'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions'
|
||||
|
||||
const baseContext: SessionContextSnapshot = {
|
||||
categories: [
|
||||
{
|
||||
name: 'memory',
|
||||
tokens: 120,
|
||||
color: '#14b8a6',
|
||||
},
|
||||
],
|
||||
totalTokens: 120,
|
||||
maxTokens: 200000,
|
||||
rawMaxTokens: 200000,
|
||||
percentage: 0.06,
|
||||
gridRows: [],
|
||||
model: 'Claude Test',
|
||||
memoryFiles: [],
|
||||
mcpTools: [],
|
||||
agents: [],
|
||||
messageBreakdown: {
|
||||
toolCallTokens: 0,
|
||||
toolResultTokens: 0,
|
||||
attachmentTokens: 0,
|
||||
assistantMessageTokens: 0,
|
||||
userMessageTokens: 0,
|
||||
toolCallsByType: [],
|
||||
attachmentsByType: [],
|
||||
},
|
||||
}
|
||||
|
||||
function inspectionWithContext(context: SessionContextSnapshot): SessionInspectionResponse {
|
||||
return {
|
||||
active: true,
|
||||
status: {
|
||||
sessionId: 'session-1',
|
||||
workDir: '/workspace/demo',
|
||||
permissionMode: 'default',
|
||||
model: 'Claude Test',
|
||||
tools: [],
|
||||
mcpServers: [],
|
||||
},
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LocalSlashCommandPanel memory context', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState(useTabStore.getInitialState(), true)
|
||||
useUIStore.setState({
|
||||
pendingMemoryPath: null,
|
||||
pendingSettingsTab: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loaded memory files and opens the selected project memory in settings', async () => {
|
||||
sessionsApiMock.getInspection.mockResolvedValue(inspectionWithContext({
|
||||
...baseContext,
|
||||
memoryFiles: [
|
||||
{
|
||||
path: '/Users/test/.claude/projects/demo/memory/MEMORY.md',
|
||||
type: 'project',
|
||||
tokens: 4321,
|
||||
},
|
||||
{
|
||||
path: '/Users/test/.claude/projects/demo/memory/feedback/reuse.md',
|
||||
type: 'feedback',
|
||||
tokens: 98,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
render(
|
||||
<LocalSlashCommandPanel
|
||||
command="context"
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Memory files')).toBeInTheDocument()
|
||||
expect(screen.getByText('MEMORY.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('/Users/test/.claude/projects/demo/memory/MEMORY.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('feedback')).toBeInTheDocument()
|
||||
expect(screen.getByText('4,321 tokens')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Memory' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
|
||||
expect(useUIStore.getState().pendingMemoryPath).toBe('/Users/test/.claude/projects/demo/memory/MEMORY.md')
|
||||
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the memory settings entry available when no memory files are loaded', async () => {
|
||||
sessionsApiMock.getInspection.mockResolvedValue(inspectionWithContext({
|
||||
...baseContext,
|
||||
memoryFiles: [],
|
||||
}))
|
||||
|
||||
render(
|
||||
<LocalSlashCommandPanel
|
||||
command="context"
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('No memory files are loaded in this session.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Memory' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
|
||||
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
|
||||
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings()}
|
||||
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
|
||||
>
|
||||
{t('slash.inspector.context.openMemory')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-[var(--color-inspector-muted)]">{t('slash.inspector.context.noMemoryFiles')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings(files[0]?.path)}
|
||||
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
|
||||
>
|
||||
{t('slash.inspector.context.openMemory')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={`${file.type}:${file.path}`}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-sm font-semibold text-[var(--color-inspector-text)]" title={file.path}>
|
||||
{memoryContextFileLabel(file.path)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-inspector-muted)]" title={file.path}>
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right font-mono">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-inspector-muted-strong)]">{file.type}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{formatNumber(file.tokens)} tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="min-w-0 font-mono">
|
||||
@ -509,6 +579,7 @@ function ContextTab({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ContextOverview context={context} categories={categories} t={t} />
|
||||
<MemoryFilesBreakdown files={Array.isArray(context.memoryFiles) ? context.memoryFiles : []} t={t} />
|
||||
<CategoryBreakdown categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
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'
|
||||
|
||||
@ -101,6 +102,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() } })
|
||||
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
|
||||
@ -195,6 +197,158 @@ 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(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
|
||||
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('promotes memory file writes from tool calls into a dedicated memory card', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-write-memory',
|
||||
type: 'tool_use',
|
||||
toolName: 'Write',
|
||||
toolUseId: 'write-memory',
|
||||
input: {
|
||||
file_path: '/Users/test/.claude/projects/example/memory/preferences.md',
|
||||
content: '# Preferences\n',
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-write-memory',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'write-memory',
|
||||
content: 'File written successfully',
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
|
||||
expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
|
||||
expect(screen.getByText('preferences.md')).toBeTruthy()
|
||||
expect(screen.getByText('Tool details')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('promotes memory file reads into collapsible memory references', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-read-memory-1',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-memory-1',
|
||||
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-read-memory-1',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'read-memory-1',
|
||||
content: '1 # Project Memory\n2\n3 billing ledger rules',
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'tool-read-memory-2',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-memory-2',
|
||||
input: { file_path: '/Users/test/.claude/projects/example/memory/workflow.md' },
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
|
||||
expect(screen.getByText('2 memory reference(s)')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('2 memory reference(s)'))
|
||||
expect(screen.getByText('MEMORY.md')).toBeTruthy()
|
||||
expect(screen.getByText('workflow.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps non-memory tools visible when a tool group also touches memory files', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-read-memory',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-memory',
|
||||
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'tool-bash',
|
||||
type: 'tool_use',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'bash-1',
|
||||
input: { command: 'bun test' },
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'result-bash',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'bash-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
|
||||
expect(screen.getByText('1 memory reference(s)')).toBeTruthy()
|
||||
expect(screen.getByText('Bash')).toBeTruthy()
|
||||
expect(screen.getByText('bun test')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps root tool runs split when nested child tool calls appear between them', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } 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 { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
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'
|
||||
@ -25,6 +25,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
|
||||
@ -393,6 +394,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 (
|
||||
<div className="mb-3 flex justify-center px-3">
|
||||
<div className="w-full max-w-2xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
|
||||
<BookMarked size={15} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="font-medium text-[var(--color-text-primary)]">
|
||||
{t('chat.memorySavedTitle', { count: message.files.length })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMemorySettings(message.files[0]?.path)}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/50 hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<Settings size={13} aria-hidden="true" />
|
||||
{t('chat.memoryOpenSettings')}
|
||||
</button>
|
||||
</div>
|
||||
{message.message ? (
|
||||
<div className="mt-1 text-[var(--color-text-tertiary)]">{message.message}</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{visibleFiles.map((file) => (
|
||||
<span
|
||||
key={file.path}
|
||||
title={file.path}
|
||||
className="max-w-full truncate rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{memoryFileLabel(file.path)}
|
||||
</span>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<span className="rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.memoryMoreFiles', { count: hiddenCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MessageListProps = {
|
||||
sessionId?: string | null
|
||||
compact?: boolean
|
||||
@ -910,6 +975,8 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
}
|
||||
case 'task_summary':
|
||||
return <InlineTaskSummary tasks={message.tasks} />
|
||||
case 'memory_event':
|
||||
return <MemoryEventCard message={message} />
|
||||
case 'system':
|
||||
return (
|
||||
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
|
||||
@ -1,14 +1,31 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
type MemoryToolAction = 'saved' | 'referenced'
|
||||
|
||||
type MemoryToolFile = {
|
||||
path: string
|
||||
label: string
|
||||
action: MemoryToolAction
|
||||
lineHint?: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type MemoryToolActivity = {
|
||||
action: MemoryToolAction
|
||||
files: MemoryToolFile[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
toolCalls: ToolCall[]
|
||||
@ -59,6 +76,59 @@ export function ToolCallGroup({
|
||||
childToolCallsByParent,
|
||||
agentTaskNotifications,
|
||||
isStreaming,
|
||||
}: Props) {
|
||||
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
|
||||
if (memoryActivity) {
|
||||
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
|
||||
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
|
||||
if (regularToolCalls.length > 0) {
|
||||
return (
|
||||
<div className="mb-2 space-y-2">
|
||||
<MemoryToolActivityGroup
|
||||
activity={memoryActivity}
|
||||
toolCalls={memoryToolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<ToolCallGroupContent
|
||||
toolCalls={regularToolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MemoryToolActivityGroup
|
||||
activity={memoryActivity}
|
||||
toolCalls={memoryToolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCallGroupContent
|
||||
toolCalls={toolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCallGroupContent({
|
||||
toolCalls,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
agentTaskNotifications,
|
||||
isStreaming,
|
||||
}: Props) {
|
||||
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
|
||||
|
||||
@ -97,6 +167,123 @@ export function ToolCallGroup({
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryToolActivityGroup({
|
||||
activity,
|
||||
toolCalls,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
isStreaming,
|
||||
}: {
|
||||
activity: MemoryToolActivity
|
||||
toolCalls: ToolCall[]
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
isStreaming?: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(activity.action === 'saved')
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false)
|
||||
const t = useTranslation()
|
||||
const titleKey = activity.action === 'saved'
|
||||
? 'chat.memorySavedFromToolsTitle'
|
||||
: 'chat.memoryReferencedTitle'
|
||||
const visibleFiles = activity.files.slice(0, 4)
|
||||
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) setExpanded(true)
|
||||
}, [isStreaming])
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="overflow-hidden rounded-lg border border-[color-mix(in_srgb,var(--color-brand)_22%,var(--color-border))] bg-[color-mix(in_srgb,var(--color-brand)_6%,var(--color-surface-container-lowest))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||
)}
|
||||
<BookMarked size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{t(titleKey, { count: activity.files.length })}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
|
||||
<div className="space-y-1.5">
|
||||
{visibleFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
title={file.path}
|
||||
onClick={() => openMemorySettings(file.path)}
|
||||
className="group flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-brand)]">
|
||||
<Settings size={12} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{file.label}
|
||||
</span>
|
||||
{file.lineHint ? (
|
||||
<span className="shrink-0 text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{file.lineHint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{file.preview ? (
|
||||
<span className="mt-0.5 line-clamp-2 text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
||||
{file.preview}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="px-2 py-1 text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.memoryMoreFiles', { count: hiddenCount })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((value) => !value)}
|
||||
className="mt-2 inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{detailsExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('chat.memoryTechnicalDetails')}
|
||||
</button>
|
||||
|
||||
{detailsExpanded ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{toolCalls.map((toolCall) => (
|
||||
<ToolCallTree
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentToolGroup({
|
||||
toolCalls,
|
||||
resultMap,
|
||||
@ -442,6 +629,108 @@ function ToolCallTree({
|
||||
)
|
||||
}
|
||||
|
||||
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 getMemoryToolActivity(
|
||||
toolCalls: ToolCall[],
|
||||
resultMap: Map<string, ToolResult>,
|
||||
): MemoryToolActivity | null {
|
||||
const filesByPath = new Map<string, MemoryToolFile>()
|
||||
let sawSave = false
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const path = getToolFilePath(toolCall.input)
|
||||
if (!path || !isMemoryMarkdownPath(path)) continue
|
||||
|
||||
const isSave = isMemoryWriteTool(toolCall.toolName)
|
||||
const isReference = toolCall.toolName === 'Read'
|
||||
if (!isSave && !isReference) continue
|
||||
sawSave ||= isSave
|
||||
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
const preview = extractMemoryPreview(result?.content)
|
||||
const current = filesByPath.get(path)
|
||||
filesByPath.set(path, {
|
||||
path,
|
||||
label: memoryFileLabel(path),
|
||||
action: isSave ? 'saved' : (current?.action ?? 'referenced'),
|
||||
lineHint: preview.lineHint || current?.lineHint,
|
||||
preview: preview.text || current?.preview,
|
||||
})
|
||||
}
|
||||
|
||||
if (filesByPath.size === 0) return null
|
||||
return {
|
||||
action: sawSave ? 'saved' : 'referenced',
|
||||
files: [...filesByPath.values()],
|
||||
}
|
||||
}
|
||||
|
||||
function isMemoryToolCall(toolCall: ToolCall): boolean {
|
||||
const path = getToolFilePath(toolCall.input)
|
||||
if (!path || !isMemoryMarkdownPath(path)) return false
|
||||
return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName)
|
||||
}
|
||||
|
||||
function isMemoryWriteTool(toolName: string): boolean {
|
||||
return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit'
|
||||
}
|
||||
|
||||
function getToolFilePath(input: unknown): string | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const record = input as Record<string, unknown>
|
||||
const filePath = record.file_path ?? record.path
|
||||
return typeof filePath === 'string' ? filePath : null
|
||||
}
|
||||
|
||||
function isMemoryMarkdownPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
return normalized.endsWith('.md') && normalized.includes('/memory/')
|
||||
}
|
||||
|
||||
function memoryFileLabel(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
return normalized.split('/').pop() || normalized
|
||||
}
|
||||
|
||||
function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } {
|
||||
const raw = extractTextContent(content)
|
||||
if (!raw) return {}
|
||||
const lineHint = extractLineHint(raw)
|
||||
const lines = raw
|
||||
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/^\s*\d+\s*/, '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let inFrontmatter = false
|
||||
for (const line of lines) {
|
||||
if (line === '---') {
|
||||
inFrontmatter = !inFrontmatter
|
||||
continue
|
||||
}
|
||||
if (inFrontmatter) continue
|
||||
const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim()
|
||||
if (!normalized || normalized === '---') continue
|
||||
if (/^(file|lines?|total)\b/i.test(normalized)) continue
|
||||
return {
|
||||
text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized,
|
||||
lineHint,
|
||||
}
|
||||
}
|
||||
return { lineHint }
|
||||
}
|
||||
|
||||
function extractLineHint(text: string): string | undefined {
|
||||
const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/)
|
||||
return match?.[1] ? `${match[1]} lines` : undefined
|
||||
}
|
||||
|
||||
type AgentStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped'
|
||||
type AgentTaskStatus = AgentTaskNotification['status']
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -547,6 +547,33 @@ 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/<project>/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.emptyProjects': 'No memory projects found.',
|
||||
'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',
|
||||
'settings.memory.indexFile': 'Index',
|
||||
'settings.memory.missing': 'Missing',
|
||||
'settings.memory.fileCount': '{count} files',
|
||||
'settings.memory.unsaved': 'Unsaved',
|
||||
'settings.memory.saved': 'Saved',
|
||||
'settings.memory.revert': 'Revert',
|
||||
'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.clearSearch': 'Clear search',
|
||||
|
||||
// Settings > Plugins
|
||||
'settings.plugins.title': 'Installed Plugins',
|
||||
'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.',
|
||||
@ -903,6 +930,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',
|
||||
@ -925,6 +955,12 @@ export const en = {
|
||||
'chat.dismiss': 'dismiss',
|
||||
'chat.stopTitle': 'Stop generation (Cmd+.)',
|
||||
'chat.jumpToLatest': 'Latest',
|
||||
'chat.memorySavedTitle': 'Saved {count} memory file(s)',
|
||||
'chat.memorySavedFromToolsTitle': 'Saved {count} memory item(s)',
|
||||
'chat.memoryReferencedTitle': '{count} memory reference(s)',
|
||||
'chat.memoryOpenSettings': 'Open Memory',
|
||||
'chat.memoryMoreFiles': '+{count} more',
|
||||
'chat.memoryTechnicalDetails': 'Tool details',
|
||||
'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',
|
||||
|
||||
@ -549,6 +549,33 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.skills.source.mcp': 'MCP',
|
||||
'settings.skills.source.bundled': '内置',
|
||||
|
||||
// Settings > Memory
|
||||
'settings.tab.memory': '记忆',
|
||||
'settings.memory.title': '项目记忆',
|
||||
'settings.memory.description': '查看和编辑 Claude 为每个项目写入的 Markdown 记忆文件。这些文件存放在 ~/.claude/projects/<project>/memory/,会被 CLI 运行时加载。',
|
||||
'settings.memory.refresh': '刷新',
|
||||
'settings.memory.projects': '项目',
|
||||
'settings.memory.files': '记忆文件',
|
||||
'settings.memory.editor': '编辑',
|
||||
'settings.memory.preview': '预览',
|
||||
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
||||
'settings.memory.emptyFiles': '还没有找到记忆文件。',
|
||||
'settings.memory.selectFile': '选择一个记忆文件。',
|
||||
'settings.memory.selectProject': '选择一个项目。',
|
||||
'settings.memory.noFileSelected': '未选择文件',
|
||||
'settings.memory.current': '当前',
|
||||
'settings.memory.indexFile': '索引',
|
||||
'settings.memory.missing': '目录缺失',
|
||||
'settings.memory.fileCount': '{count} 个文件',
|
||||
'settings.memory.unsaved': '未保存',
|
||||
'settings.memory.saved': '已保存',
|
||||
'settings.memory.revert': '还原',
|
||||
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
|
||||
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
|
||||
'settings.memory.noProjectMatches': '没有匹配的项目。',
|
||||
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
|
||||
'settings.memory.clearSearch': '清空搜索',
|
||||
|
||||
// Settings > Plugins
|
||||
'settings.plugins.title': '已安装插件',
|
||||
'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。',
|
||||
@ -905,6 +932,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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': '上下文用量加载中',
|
||||
@ -927,6 +957,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.dismiss': '关闭',
|
||||
'chat.stopTitle': '停止生成 (Cmd+.)',
|
||||
'chat.jumpToLatest': '回到最新',
|
||||
'chat.memorySavedTitle': '已保存 {count} 个记忆文件',
|
||||
'chat.memorySavedFromToolsTitle': '保存了 {count} 条记忆',
|
||||
'chat.memoryReferencedTitle': '{count} 条记忆引用',
|
||||
'chat.memoryOpenSettings': '打开记忆',
|
||||
'chat.memoryMoreFiles': '还有 {count} 个',
|
||||
'chat.memoryTechnicalDetails': '工具详情',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
|
||||
528
desktop/src/pages/MemorySettings.tsx
Normal file
528
desktop/src/pages/MemorySettings.tsx
Normal file
@ -0,0 +1,528 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BookOpenText, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
|
||||
import { Button } from '../components/shared/Button'
|
||||
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,
|
||||
} = 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 [projectQuery, setProjectQuery] = useState('')
|
||||
const [fileQuery, setFileQuery] = useState('')
|
||||
|
||||
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)
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(projects, projectQuery),
|
||||
[projectQuery, projects],
|
||||
)
|
||||
const filteredFiles = useMemo(
|
||||
() => filterFiles(files, fileQuery),
|
||||
[fileQuery, files],
|
||||
)
|
||||
const previewContent = stripMarkdownFrontmatter(draftContent)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProjects(activeCwd)
|
||||
}, [activeCwd, fetchProjects])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) return
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[640px] flex-col gap-4">
|
||||
<header className="grid gap-4 border-b border-[var(--color-border)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
|
||||
<BookOpenText size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.memory.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-10 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="truncate font-mono">{activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}</span>
|
||||
<span>{t('settings.memory.fileCount', { count: files.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
loading={isLoadingProjects || isLoadingFiles}
|
||||
icon={<RefreshCw size={15} aria-hidden="true" />}
|
||||
>
|
||||
{t('settings.memory.refresh')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[390px_minmax(0,1fr)]">
|
||||
<aside className="grid min-h-0 grid-rows-[minmax(210px,0.9fr)_minmax(260px,1.1fr)] overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<section className="flex min-h-0 flex-col">
|
||||
<PanelHeader
|
||||
icon={<FolderGit2 size={15} aria-hidden="true" />}
|
||||
title={t('settings.memory.projects')}
|
||||
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
|
||||
/>
|
||||
<div className="px-3 py-3">
|
||||
<SearchField
|
||||
value={projectQuery}
|
||||
onChange={setProjectQuery}
|
||||
placeholder={t('settings.memory.projectSearchPlaceholder')}
|
||||
ariaLabel={t('settings.memory.projectSearchPlaceholder')}
|
||||
clearLabel={t('settings.memory.clearSearch')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{projects.length === 0 && !isLoadingProjects ? (
|
||||
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
active={project.id === selectedProjectId}
|
||||
onSelect={() => handleProjectSelect(project.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col border-t border-[var(--color-border)]">
|
||||
<PanelHeader
|
||||
icon={<Database size={15} aria-hidden="true" />}
|
||||
title={t('settings.memory.files')}
|
||||
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
|
||||
/>
|
||||
<div className="px-3 py-3">
|
||||
<SearchField
|
||||
value={fileQuery}
|
||||
onChange={setFileQuery}
|
||||
placeholder={t('settings.memory.fileSearchPlaceholder')}
|
||||
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
|
||||
clearLabel={t('settings.memory.clearSearch')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{files.length === 0 && !isLoadingFiles ? (
|
||||
<EmptyState icon={<FileText size={18} />} text={t('settings.memory.emptyFiles')} />
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noFileMatches')} />
|
||||
) : (
|
||||
filteredFiles.map((file) => (
|
||||
<FileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
onSelect={() => handleFileOpen(file)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="min-h-0 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="grid gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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>}
|
||||
{lastSavedAt && !isDirty && <Badge>{t('settings.memory.saved')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{selectedProject?.memoryDir ?? t('settings.memory.selectProject')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!selectedFile || !isDirty || isSaving}
|
||||
onClick={() => selectedFile && updateDraft(selectedFile.content)}
|
||||
icon={<RotateCcw size={14} aria-hidden="true" />}
|
||||
>
|
||||
{t('settings.memory.revert')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!selectedFile || !isDirty}
|
||||
loading={isSaving}
|
||||
onClick={() => void saveFile()}
|
||||
icon={<Save size={14} aria-hidden="true" />}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="grid min-h-[560px] grid-rows-[minmax(300px,1fr)_minmax(260px,0.95fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 2xl:grid-rows-1">
|
||||
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.memory.editor')}</span>
|
||||
<span>{formatBytes(selectedFile.bytes)}</span>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label={t('settings.memory.editor')}
|
||||
value={draftContent}
|
||||
onChange={(event) => updateDraft(event.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-[calc(100%-40px)] 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">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.memory.preview')}</span>
|
||||
<span>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<MarkdownRenderer content={previewContent || ' '} variant="document" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center p-8">
|
||||
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-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 PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta: string }) {
|
||||
return (
|
||||
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
|
||||
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
project,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
project: MemoryProject
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const display = projectDisplayName(project.label)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
title={project.label}
|
||||
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||
active
|
||||
? '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-semibold">{display}</span>
|
||||
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
|
||||
</div>
|
||||
<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>
|
||||
{!project.exists ? <span>{t('settings.memory.missing')}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
file: MemoryFile
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||
active
|
||||
? '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="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>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.isIndex ? <Badge>{t('settings.memory.indexFile')}</Badge> : null}
|
||||
{file.type && <Badge>{file.type}</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">{file.path}</p>
|
||||
{file.description && (
|
||||
<p className="mt-1 truncate text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{file.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span>{formatBytes(file.bytes)}</span>
|
||||
{file.updatedAt ? <span>{formatDate(file.updatedAt)}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-2 px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
|
||||
{icon ? (
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(/\/+$/, '')
|
||||
}
|
||||
|
||||
function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string): { projectId: string; path: string } | null {
|
||||
const target = normalizeFsPath(absolutePath)
|
||||
for (const project of projects) {
|
||||
const memoryDir = normalizeFsPath(project.memoryDir)
|
||||
if (!memoryDir) continue
|
||||
if (target === memoryDir) {
|
||||
return { projectId: project.id, path: DEFAULT_MEMORY_PATH }
|
||||
}
|
||||
if (target.startsWith(`${memoryDir}/`)) {
|
||||
return {
|
||||
projectId: project.id,
|
||||
path: target.slice(memoryDir.length + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
@ -29,6 +29,7 @@ import { McpSettings } from './McpSettings'
|
||||
import { TerminalSettings } from './TerminalSettings'
|
||||
import { DiagnosticsSettings } from './DiagnosticsSettings'
|
||||
import { ActivitySettings } from './ActivitySettings'
|
||||
import { MemorySettings } from './MemorySettings'
|
||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
@ -92,6 +93,7 @@ export function Settings() {
|
||||
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
|
||||
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
|
||||
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
|
||||
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
||||
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
|
||||
@ -114,6 +116,7 @@ export function Settings() {
|
||||
{activeTab === 'mcp' && <McpSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
{activeTab === 'memory' && <MemorySettings />}
|
||||
{activeTab === 'plugins' && <PluginSettings />}
|
||||
{activeTab === 'computerUse' && <ComputerUseSettings />}
|
||||
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
|
||||
|
||||
@ -219,6 +219,37 @@ describe('chatStore history mapping', () => {
|
||||
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
|
||||
})
|
||||
|
||||
it('restores saved memory system events from transcript history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'memory-1',
|
||||
type: 'system',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: {
|
||||
subtype: 'memory_saved',
|
||||
writtenPaths: ['/Users/test/.claude/projects/example/memory/preferences.md'],
|
||||
teamCount: 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
expect(mapped).toMatchObject([
|
||||
{
|
||||
id: 'memory-1',
|
||||
type: 'memory_event',
|
||||
event: 'saved',
|
||||
files: [
|
||||
{
|
||||
path: '/Users/test/.claude/projects/example/memory/preferences.md',
|
||||
action: 'saved',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('merges consecutive assistant text blocks when restoring transcript history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
@ -1084,6 +1115,43 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders memory saved notifications as chat memory events', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'memory_saved',
|
||||
message: 'Saved 2 memories',
|
||||
data: {
|
||||
writtenPaths: [
|
||||
'/Users/test/.claude/projects/example/memory/preferences.md',
|
||||
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
|
||||
],
|
||||
teamCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'memory_event',
|
||||
event: 'saved',
|
||||
message: 'Saved 2 memories',
|
||||
teamCount: 1,
|
||||
files: [
|
||||
{ path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
|
||||
{ path: '/Users/test/.claude/projects/example/memory/team/MEMORY.md', action: 'saved' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('flushes the previous assistant draft before starting a new user turn', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -19,6 +19,7 @@ import type {
|
||||
ChatState,
|
||||
ComputerUsePermissionRequest,
|
||||
ComputerUsePermissionResponse,
|
||||
MemoryEventFile,
|
||||
UIAttachment,
|
||||
UIMessage,
|
||||
ServerMessage,
|
||||
@ -210,6 +211,23 @@ function appendAssistantTextMessage(
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
|
||||
if (!Array.isArray(writtenPaths)) return []
|
||||
return writtenPaths
|
||||
.filter((path): path is string => typeof path === 'string' && path.trim().length > 0)
|
||||
.map((path) => ({ path, action: 'saved' as const }))
|
||||
}
|
||||
|
||||
function normalizeMemoryTeamCount(data: unknown): number | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined
|
||||
const teamCount = (data as { teamCount?: unknown }).teamCount
|
||||
return typeof teamCount === 'number' && Number.isFinite(teamCount)
|
||||
? teamCount
|
||||
: undefined
|
||||
}
|
||||
|
||||
function normalizeNotificationPreview(content: string): string {
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, ' code block ')
|
||||
@ -922,6 +940,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
],
|
||||
}))
|
||||
}
|
||||
if (msg.subtype === 'memory_saved') {
|
||||
const files = normalizeMemoryEventFiles(msg.data)
|
||||
if (files.length > 0) {
|
||||
update((session) => ({
|
||||
messages: [
|
||||
...session.messages,
|
||||
{
|
||||
id: nextId(),
|
||||
type: 'memory_event',
|
||||
event: 'saved',
|
||||
files,
|
||||
message: msg.message,
|
||||
teamCount: normalizeMemoryTeamCount(msg.data),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
|
||||
const data = msg.data as Record<string, unknown>
|
||||
const toolUseId =
|
||||
@ -1338,6 +1375,25 @@ export function mapHistoryMessagesToUiMessages(
|
||||
})
|
||||
}
|
||||
}
|
||||
if (msg.type === 'system' && msg.content && typeof msg.content === 'object') {
|
||||
const subtype = (msg.content as { subtype?: unknown }).subtype
|
||||
if (subtype === 'memory_saved') {
|
||||
const files = normalizeMemoryEventFiles(msg.content)
|
||||
if (files.length > 0) {
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'memory_event',
|
||||
event: 'saved',
|
||||
files,
|
||||
message: typeof (msg.content as { message?: unknown }).message === 'string'
|
||||
? (msg.content as { message: string }).message
|
||||
: undefined,
|
||||
teamCount: normalizeMemoryTeamCount(msg.content),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uiMessages
|
||||
}
|
||||
|
||||
138
desktop/src/stores/memoryStore.ts
Normal file
138
desktop/src/stores/memoryStore.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { create } from 'zustand'
|
||||
import { memoryApi } from '../api/memory'
|
||||
import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory'
|
||||
|
||||
type MemoryStore = {
|
||||
projects: MemoryProject[]
|
||||
files: MemoryFile[]
|
||||
selectedProjectId: string | null
|
||||
selectedFile: MemoryFileDetail | null
|
||||
draftContent: string
|
||||
isLoadingProjects: boolean
|
||||
isLoadingFiles: boolean
|
||||
isLoadingFile: boolean
|
||||
isSaving: boolean
|
||||
error: string | null
|
||||
lastSavedAt: string | null
|
||||
|
||||
fetchProjects: (cwd?: string) => Promise<void>
|
||||
selectProject: (projectId: string) => void
|
||||
fetchFiles: (projectId: string) => Promise<void>
|
||||
openFile: (projectId: string, path: string) => Promise<void>
|
||||
updateDraft: (content: string) => void
|
||||
saveFile: () => Promise<void>
|
||||
createFile: (projectId: string, path: string, content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useMemoryStore = create<MemoryStore>((set, get) => ({
|
||||
projects: [],
|
||||
files: [],
|
||||
selectedProjectId: null,
|
||||
selectedFile: null,
|
||||
draftContent: '',
|
||||
isLoadingProjects: false,
|
||||
isLoadingFiles: false,
|
||||
isLoadingFile: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
lastSavedAt: null,
|
||||
|
||||
fetchProjects: async (cwd) => {
|
||||
set({ isLoadingProjects: true, error: null })
|
||||
try {
|
||||
const { projects } = await memoryApi.listProjects(cwd)
|
||||
const current = projects.find((project) => project.isCurrent)
|
||||
const selectedProjectId =
|
||||
get().selectedProjectId && projects.some((project) => project.id === get().selectedProjectId)
|
||||
? get().selectedProjectId
|
||||
: current?.id ?? projects[0]?.id ?? null
|
||||
set({ projects, selectedProjectId, isLoadingProjects: false })
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isLoadingProjects: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectProject: (projectId) => {
|
||||
set({
|
||||
selectedProjectId: projectId,
|
||||
files: [],
|
||||
selectedFile: null,
|
||||
draftContent: '',
|
||||
error: null,
|
||||
lastSavedAt: null,
|
||||
})
|
||||
},
|
||||
|
||||
fetchFiles: async (projectId) => {
|
||||
set({ isLoadingFiles: true, error: null })
|
||||
try {
|
||||
const { files } = await memoryApi.listFiles(projectId)
|
||||
set((state) => {
|
||||
const stillSelected = state.selectedFile && files.some((file) => file.path === state.selectedFile?.path)
|
||||
return {
|
||||
files,
|
||||
selectedFile: stillSelected ? state.selectedFile : null,
|
||||
draftContent: stillSelected ? state.draftContent : '',
|
||||
isLoadingFiles: false,
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isLoadingFiles: false })
|
||||
}
|
||||
},
|
||||
|
||||
openFile: async (projectId, path) => {
|
||||
set({ isLoadingFile: true, error: null })
|
||||
try {
|
||||
const { file } = await memoryApi.readFile(projectId, path)
|
||||
set({
|
||||
selectedFile: file,
|
||||
draftContent: file.content,
|
||||
isLoadingFile: false,
|
||||
lastSavedAt: null,
|
||||
})
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isLoadingFile: false })
|
||||
}
|
||||
},
|
||||
|
||||
updateDraft: (content) => set({ draftContent: content }),
|
||||
|
||||
saveFile: async () => {
|
||||
const { selectedProjectId, selectedFile, draftContent } = get()
|
||||
if (!selectedProjectId || !selectedFile) return
|
||||
set({ isSaving: true, error: null })
|
||||
try {
|
||||
const { file } = await memoryApi.saveFile({
|
||||
projectId: selectedProjectId,
|
||||
path: selectedFile.path,
|
||||
content: draftContent,
|
||||
})
|
||||
set({
|
||||
selectedFile: {
|
||||
...selectedFile,
|
||||
updatedAt: file.updatedAt,
|
||||
bytes: file.bytes,
|
||||
content: draftContent,
|
||||
},
|
||||
isSaving: false,
|
||||
lastSavedAt: file.updatedAt,
|
||||
})
|
||||
await get().fetchFiles(selectedProjectId)
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isSaving: false })
|
||||
}
|
||||
},
|
||||
|
||||
createFile: async (projectId, path, content) => {
|
||||
set({ isSaving: true, error: null })
|
||||
try {
|
||||
await memoryApi.saveFile({ projectId, path, content })
|
||||
set({ isSaving: false })
|
||||
await get().fetchFiles(projectId)
|
||||
await get().openFile(projectId, path)
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isSaving: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@ -39,6 +39,7 @@ export type SettingsTab =
|
||||
| 'mcp'
|
||||
| 'agents'
|
||||
| 'skills'
|
||||
| 'memory'
|
||||
| 'plugins'
|
||||
| 'computerUse'
|
||||
| 'diagnostics'
|
||||
@ -51,6 +52,7 @@ type UIStore = {
|
||||
sidebarOpen: boolean
|
||||
activeView: ActiveView
|
||||
pendingSettingsTab: SettingsTab | null
|
||||
pendingMemoryPath: string | null
|
||||
activeModal: string | null
|
||||
toasts: Toast[]
|
||||
|
||||
@ -60,6 +62,7 @@ type UIStore = {
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
setActiveView: (view: ActiveView) => void
|
||||
setPendingSettingsTab: (tab: SettingsTab | null) => void
|
||||
setPendingMemoryPath: (path: string | null) => void
|
||||
openModal: (id: string) => void
|
||||
closeModal: () => void
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void
|
||||
@ -73,6 +76,7 @@ export const useUIStore = create<UIStore>((set) => ({
|
||||
sidebarOpen: true,
|
||||
activeView: 'code',
|
||||
pendingSettingsTab: null,
|
||||
pendingMemoryPath: null,
|
||||
activeModal: null,
|
||||
toasts: [],
|
||||
|
||||
@ -96,6 +100,7 @@ export const useUIStore = create<UIStore>((set) => ({
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
|
||||
setPendingMemoryPath: (path) => set({ pendingMemoryPath: path }),
|
||||
openModal: (id) => set({ activeModal: id }),
|
||||
closeModal: () => set({ activeModal: null }),
|
||||
|
||||
|
||||
@ -158,6 +158,12 @@ export type AgentTaskNotification = {
|
||||
outputFile?: string
|
||||
}
|
||||
|
||||
export type MemoryEventFile = {
|
||||
path: string
|
||||
action?: 'saved' | 'updated' | 'created' | 'deleted' | 'loaded' | 'failed'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
// ─── UI Message model (rendered in MessageList) ───────────────────
|
||||
|
||||
export type TaskSummaryItem = {
|
||||
@ -174,6 +180,15 @@ export type UIMessage =
|
||||
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
|
||||
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }
|
||||
| { id: string; type: 'system'; content: string; timestamp: number }
|
||||
| {
|
||||
id: string
|
||||
type: 'memory_event'
|
||||
event: 'saved' | 'updated' | 'loaded' | 'failed'
|
||||
files: MemoryEventFile[]
|
||||
message?: string
|
||||
teamCount?: number
|
||||
timestamp: number
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: 'permission_request'
|
||||
|
||||
26
desktop/src/types/memory.ts
Normal file
26
desktop/src/types/memory.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type MemoryProject = {
|
||||
id: string
|
||||
label: string
|
||||
memoryDir: string
|
||||
exists: boolean
|
||||
fileCount: number
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
export type MemoryFile = {
|
||||
path: string
|
||||
name: string
|
||||
bytes: number
|
||||
updatedAt: string
|
||||
type?: string
|
||||
description?: string
|
||||
title: string
|
||||
isIndex: boolean
|
||||
}
|
||||
|
||||
export type MemoryFileDetail = {
|
||||
path: string
|
||||
content: string
|
||||
updatedAt: string
|
||||
bytes: number
|
||||
}
|
||||
189
src/server/__tests__/memory.test.ts
Normal file
189
src/server/__tests__/memory.test.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleMemoryApi } from '../api/memory.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHome: string | undefined
|
||||
let originalUserProfile: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-memory-api-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalHome = process.env.HOME
|
||||
originalUserProfile = process.env.USERPROFILE
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
process.env.USERPROFILE = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome
|
||||
} else {
|
||||
delete process.env.HOME
|
||||
}
|
||||
if (originalUserProfile !== undefined) {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
} else {
|
||||
delete process.env.USERPROFILE
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('memory API', () => {
|
||||
it('lists current project memory files with frontmatter metadata', async () => {
|
||||
const cwd = path.join(tmpDir, 'workspace', 'app')
|
||||
const projectId = sanitizePath(cwd)
|
||||
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
|
||||
await fs.mkdir(path.join(memoryDir, 'notes'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, 'MEMORY.md'),
|
||||
[
|
||||
'---',
|
||||
'type: project',
|
||||
'description: Stable project conventions.',
|
||||
'---',
|
||||
'',
|
||||
'# Project Memory',
|
||||
].join('\n'),
|
||||
)
|
||||
await fs.writeFile(path.join(memoryDir, 'notes', 'manual.md'), '# Manual')
|
||||
|
||||
const projectsRes = await request('GET', `/api/memory/projects?cwd=${encodeURIComponent(cwd)}`)
|
||||
expect(projectsRes.status).toBe(200)
|
||||
const projectsBody = await projectsRes.json() as {
|
||||
projects: Array<{ id: string; isCurrent: boolean; fileCount: number }>
|
||||
}
|
||||
expect(projectsBody.projects[0]).toMatchObject({
|
||||
id: projectId,
|
||||
isCurrent: true,
|
||||
fileCount: 2,
|
||||
})
|
||||
|
||||
const filesRes = await request('GET', `/api/memory/files?projectId=${encodeURIComponent(projectId)}`)
|
||||
expect(filesRes.status).toBe(200)
|
||||
const filesBody = await filesRes.json() as {
|
||||
files: Array<{ path: string; type?: string; description?: string; isIndex: boolean }>
|
||||
}
|
||||
expect(filesBody.files[0]).toMatchObject({
|
||||
path: 'MEMORY.md',
|
||||
type: 'project',
|
||||
description: 'Stable project conventions.',
|
||||
isIndex: true,
|
||||
})
|
||||
expect(filesBody.files.some((file) => file.path === 'notes/manual.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses session metadata for project labels when sanitized paths contain non-ascii characters', async () => {
|
||||
const cwd = path.join(tmpDir, '中文 项目', 'GLM', '5V', 'turbo')
|
||||
const projectId = sanitizePath(cwd)
|
||||
const projectDir = path.join(tmpDir, 'projects', projectId)
|
||||
const memoryDir = path.join(projectDir, 'memory')
|
||||
await fs.mkdir(memoryDir, { recursive: true })
|
||||
await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory')
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'session.jsonl'),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
cwd,
|
||||
message: { role: 'user', content: 'hello' },
|
||||
}) + '\n',
|
||||
)
|
||||
|
||||
const projectsRes = await request('GET', '/api/memory/projects')
|
||||
expect(projectsRes.status).toBe(200)
|
||||
const projectsBody = await projectsRes.json() as {
|
||||
projects: Array<{ id: string; label: string }>
|
||||
}
|
||||
|
||||
const project = projectsBody.projects.find((item) => item.id === projectId)
|
||||
expect(project).toMatchObject({ label: cwd })
|
||||
})
|
||||
|
||||
it('recovers project labels from existing directories when no session metadata exists', async () => {
|
||||
const cwd = path.join(tmpDir, '个人自媒体', '314', 'opus4', 'PicTacticAgent')
|
||||
const projectId = sanitizePath(cwd)
|
||||
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
await fs.mkdir(memoryDir, { recursive: true })
|
||||
await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory')
|
||||
|
||||
const projectsRes = await request('GET', '/api/memory/projects')
|
||||
expect(projectsRes.status).toBe(200)
|
||||
const projectsBody = await projectsRes.json() as {
|
||||
projects: Array<{ id: string; label: string }>
|
||||
}
|
||||
|
||||
const project = projectsBody.projects.find((item) => item.id === projectId)
|
||||
expect(project).toMatchObject({ label: cwd })
|
||||
})
|
||||
|
||||
it('reads and writes only markdown files inside the project memory directory', async () => {
|
||||
const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
|
||||
|
||||
const writeRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: 'notes/project.md',
|
||||
content: '# Edited Memory\n',
|
||||
})
|
||||
expect(writeRes.status).toBe(200)
|
||||
|
||||
const filePath = path.join(tmpDir, 'projects', projectId, 'memory', 'notes', 'project.md')
|
||||
expect(await fs.readFile(filePath, 'utf-8')).toBe('# Edited Memory\n')
|
||||
|
||||
const readRes = await request('GET', `/api/memory/file?projectId=${encodeURIComponent(projectId)}&path=notes%2Fproject.md`)
|
||||
expect(readRes.status).toBe(200)
|
||||
const body = await readRes.json() as { file: { path: string; content: string } }
|
||||
expect(body.file).toMatchObject({
|
||||
path: 'notes/project.md',
|
||||
content: '# Edited Memory\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects traversal and symlink escapes', async () => {
|
||||
const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
|
||||
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
|
||||
const outsideDir = path.join(tmpDir, 'outside')
|
||||
await fs.mkdir(memoryDir, { recursive: true })
|
||||
await fs.mkdir(outsideDir, { recursive: true })
|
||||
await fs.symlink(outsideDir, path.join(memoryDir, 'linked'), 'dir')
|
||||
|
||||
const traversalRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: '../outside.md',
|
||||
content: 'escape',
|
||||
})
|
||||
expect(traversalRes.status).toBe(400)
|
||||
|
||||
const symlinkRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: 'linked/outside.md',
|
||||
content: 'escape',
|
||||
})
|
||||
expect(symlinkRes.status).toBe(400)
|
||||
await expect(fs.readFile(path.join(outsideDir, 'outside.md'), 'utf-8')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function request(method: string, pathname: string, body?: Record<string, unknown>): Promise<Response> {
|
||||
const url = new URL(pathname, 'http://localhost:3456')
|
||||
const init: RequestInit = { method }
|
||||
if (body) {
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
return handleMemoryApi(
|
||||
new Request(url.toString(), init),
|
||||
url,
|
||||
url.pathname.split('/').filter(Boolean),
|
||||
)
|
||||
}
|
||||
36
src/server/__tests__/ws-memory-events.test.ts
Normal file
36
src/server/__tests__/ws-memory-events.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { translateCliMessage } from '../ws/handler.js'
|
||||
|
||||
describe('WebSocket memory events', () => {
|
||||
it('forwards CLI memory_saved system messages to the desktop client', () => {
|
||||
const messages = translateCliMessage(
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'memory_saved',
|
||||
writtenPaths: [
|
||||
'/Users/test/.claude/projects/example/memory/preferences.md',
|
||||
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
|
||||
],
|
||||
teamCount: 1,
|
||||
verb: 'Saved',
|
||||
},
|
||||
'session-1',
|
||||
)
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'memory_saved',
|
||||
message: undefined,
|
||||
data: {
|
||||
writtenPaths: [
|
||||
'/Users/test/.claude/projects/example/memory/preferences.md',
|
||||
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
|
||||
],
|
||||
teamCount: 1,
|
||||
verb: 'Saved',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
552
src/server/api/memory.ts
Normal file
552
src/server/api/memory.ts
Normal file
@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Memory REST API
|
||||
*
|
||||
* GET /api/memory/projects?cwd=... — list project-scoped memory dirs
|
||||
* GET /api/memory/files?projectId=... — list markdown memory files
|
||||
* GET /api/memory/file?projectId=...&path=...
|
||||
* PUT /api/memory/file — update/create a markdown memory file
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { findCanonicalGitRoot } from '../../utils/git.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
import { extractJsonStringField } from '../../utils/sessionStoragePortable.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { parseMemoryType } from '../../memdir/memoryTypes.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
type MemoryProject = {
|
||||
id: string
|
||||
label: string
|
||||
memoryDir: string
|
||||
exists: boolean
|
||||
fileCount: number
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
type MemoryFile = {
|
||||
path: string
|
||||
name: string
|
||||
bytes: number
|
||||
updatedAt: string
|
||||
type?: string
|
||||
description?: string
|
||||
title: string
|
||||
isIndex: boolean
|
||||
}
|
||||
|
||||
const MAX_MEMORY_FILE_BYTES = 512 * 1024
|
||||
const MAX_MEMORY_FILES = 500
|
||||
const PROJECT_LABEL_SESSION_SCAN_LIMIT = 10
|
||||
const PROJECT_LABEL_HEAD_BYTES = 64 * 1024
|
||||
const PROJECT_LABEL_FS_SEARCH_DEPTH = 24
|
||||
const PROJECT_LABEL_FS_SEARCH_NODE_LIMIT = 2000
|
||||
|
||||
export async function handleMemoryApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const sub = segments[2]
|
||||
|
||||
switch (sub) {
|
||||
case 'projects':
|
||||
if (req.method !== 'GET') throw methodNotAllowed(req.method)
|
||||
return Response.json({
|
||||
projects: await listMemoryProjects(url.searchParams.get('cwd') || undefined),
|
||||
})
|
||||
|
||||
case 'files':
|
||||
if (req.method !== 'GET') throw methodNotAllowed(req.method)
|
||||
return Response.json({
|
||||
files: await listMemoryFiles(requireProjectId(url)),
|
||||
})
|
||||
|
||||
case 'file':
|
||||
return await handleMemoryFile(req, url)
|
||||
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown memory endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function listMemoryProjects(cwd?: string): Promise<MemoryProject[]> {
|
||||
const projectsDir = getProjectsDir()
|
||||
const currentCwd = cwd || getCwd()
|
||||
const currentProjectId = getProjectIdForCwd(currentCwd)
|
||||
const projects = new Map<string, MemoryProject>()
|
||||
|
||||
addProject(projects, currentProjectId, true)
|
||||
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(projectsDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
addProject(projects, entry.name, entry.name === currentProjectId)
|
||||
}
|
||||
|
||||
const resolved = await Promise.all(
|
||||
Array.from(projects.values()).map(async project => {
|
||||
const [fileCount, label] = await Promise.all([
|
||||
countMarkdownFiles(project.memoryDir),
|
||||
resolveProjectLabel(project.id, currentCwd),
|
||||
])
|
||||
return {
|
||||
...project,
|
||||
label,
|
||||
exists: fileCount > 0 || (await directoryExists(project.memoryDir)),
|
||||
fileCount,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return resolved
|
||||
.filter((project) => project.isCurrent || project.exists || project.fileCount > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
|
||||
if (a.fileCount !== b.fileCount) return b.fileCount - a.fileCount
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function addProject(projects: Map<string, MemoryProject>, id: string, isCurrent: boolean) {
|
||||
if (!isValidProjectId(id)) return
|
||||
const existing = projects.get(id)
|
||||
if (existing) {
|
||||
existing.isCurrent = existing.isCurrent || isCurrent
|
||||
return
|
||||
}
|
||||
projects.set(id, {
|
||||
id,
|
||||
label: unsanitizeProjectLabel(id),
|
||||
memoryDir: path.join(getProjectsDir(), id, 'memory'),
|
||||
exists: false,
|
||||
fileCount: 0,
|
||||
isCurrent,
|
||||
})
|
||||
}
|
||||
|
||||
async function listMemoryFiles(projectId: string): Promise<MemoryFile[]> {
|
||||
const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
|
||||
if (!(await directoryExists(memoryDir))) return []
|
||||
|
||||
const files: MemoryFile[] = []
|
||||
|
||||
async function walk(dir: string, prefix = ''): Promise<void> {
|
||||
if (files.length >= MAX_MEMORY_FILES) return
|
||||
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= MAX_MEMORY_FILES) break
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
||||
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath, relativePath)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
||||
|
||||
const stat = await fs.stat(fullPath)
|
||||
let type: string | undefined
|
||||
let description: string | undefined
|
||||
try {
|
||||
if (stat.size <= MAX_MEMORY_FILE_BYTES) {
|
||||
const raw = await fs.readFile(fullPath, 'utf-8')
|
||||
const parsed = parseFrontmatter(raw, fullPath)
|
||||
type = parseMemoryType(parsed.frontmatter.type) ?? undefined
|
||||
description =
|
||||
typeof parsed.frontmatter.description === 'string'
|
||||
? parsed.frontmatter.description
|
||||
: undefined
|
||||
}
|
||||
} catch {
|
||||
// Metadata is best-effort. The file remains editable from the UI.
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: relativePath,
|
||||
name: entry.name,
|
||||
bytes: stat.size,
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
type,
|
||||
description,
|
||||
title: relativePath === 'MEMORY.md' ? 'MEMORY.md' : entry.name.replace(/\.md$/, ''),
|
||||
isIndex: relativePath === 'MEMORY.md',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await walk(memoryDir)
|
||||
return files.sort((a, b) => {
|
||||
if (a.isIndex !== b.isIndex) return a.isIndex ? -1 : 1
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMemoryFile(req: Request, url: URL): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
const projectId = requireProjectId(url)
|
||||
const relativePath = requireMemoryPath(url.searchParams.get('path'))
|
||||
const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
|
||||
mustExist: true,
|
||||
})
|
||||
const stat = await fs.stat(fullPath)
|
||||
if (stat.size > MAX_MEMORY_FILE_BYTES) {
|
||||
throw ApiError.badRequest(`Memory file is too large to edit: ${relativePath}`)
|
||||
}
|
||||
return Response.json({
|
||||
file: {
|
||||
path: relativePath,
|
||||
content: await fs.readFile(fullPath, 'utf-8'),
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
bytes: stat.size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
const projectId = typeof body.projectId === 'string' ? body.projectId : ''
|
||||
const relativePath = requireMemoryPath(
|
||||
typeof body.path === 'string' ? body.path : undefined,
|
||||
)
|
||||
const content = typeof body.content === 'string' ? body.content : undefined
|
||||
if (content === undefined) {
|
||||
throw ApiError.badRequest('Missing or invalid "content" in request body')
|
||||
}
|
||||
if (Buffer.byteLength(content, 'utf-8') > MAX_MEMORY_FILE_BYTES) {
|
||||
throw ApiError.badRequest('Memory file content exceeds 512 KB')
|
||||
}
|
||||
|
||||
const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
|
||||
mustExist: false,
|
||||
})
|
||||
const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true, mode: 0o700 })
|
||||
await assertWithinDirectory(path.dirname(fullPath), memoryDir, true)
|
||||
if (await fileExists(fullPath)) {
|
||||
await assertWithinDirectory(fullPath, memoryDir, true)
|
||||
}
|
||||
await fs.writeFile(fullPath, content, { encoding: 'utf-8', mode: 0o600 })
|
||||
const stat = await fs.stat(fullPath)
|
||||
return Response.json({
|
||||
ok: true,
|
||||
file: {
|
||||
path: relativePath,
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
bytes: stat.size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
function requireProjectId(url: URL): string {
|
||||
const projectId = url.searchParams.get('projectId')
|
||||
if (!projectId) throw ApiError.badRequest('Missing projectId')
|
||||
return projectId
|
||||
}
|
||||
|
||||
function requireMemoryPath(value: string | null | undefined): string {
|
||||
if (!value || typeof value !== 'string') {
|
||||
throw ApiError.badRequest('Missing memory file path')
|
||||
}
|
||||
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.includes('\0') ||
|
||||
normalized.split('/').some(part => part === '' || part === '.' || part === '..') ||
|
||||
!normalized.endsWith('.md')
|
||||
) {
|
||||
throw ApiError.badRequest('Memory path must be a relative .md file path')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function resolveMemoryFilePath(
|
||||
projectId: string,
|
||||
relativePath: string,
|
||||
opts: { mustExist: boolean },
|
||||
): Promise<string> {
|
||||
const memoryDir = await ensureMemoryDirBoundary(projectId, {
|
||||
mustExist: opts.mustExist,
|
||||
})
|
||||
const candidate = path.resolve(memoryDir, relativePath)
|
||||
await assertWithinDirectory(candidate, memoryDir, opts.mustExist)
|
||||
return candidate
|
||||
}
|
||||
|
||||
async function ensureMemoryDirBoundary(
|
||||
projectId: string,
|
||||
opts: { mustExist: boolean },
|
||||
): Promise<string> {
|
||||
if (!isValidProjectId(projectId)) {
|
||||
throw ApiError.badRequest('Invalid projectId')
|
||||
}
|
||||
const projectsDir = path.resolve(getProjectsDir())
|
||||
const projectDir = path.resolve(projectsDir, projectId)
|
||||
await assertWithinDirectory(projectDir, projectsDir, false)
|
||||
const memoryDir = path.resolve(projectDir, 'memory')
|
||||
if (await directoryExists(projectDir)) {
|
||||
await assertWithinDirectory(projectDir, projectsDir, true)
|
||||
}
|
||||
if (await directoryExists(memoryDir)) {
|
||||
await assertWithinDirectory(memoryDir, projectDir, true)
|
||||
}
|
||||
if (opts.mustExist && !(await directoryExists(memoryDir))) {
|
||||
throw ApiError.notFound(`Memory project not found: ${projectId}`)
|
||||
}
|
||||
return memoryDir
|
||||
}
|
||||
|
||||
async function assertWithinDirectory(
|
||||
candidate: string,
|
||||
directory: string,
|
||||
mustExist: boolean,
|
||||
): Promise<void> {
|
||||
const resolvedDirectory = mustExist
|
||||
? await safeRealpath(directory)
|
||||
: path.resolve(directory)
|
||||
const resolvedCandidate = mustExist
|
||||
? await safeRealpath(candidate)
|
||||
: path.resolve(candidate)
|
||||
const boundary = resolvedDirectory.endsWith(path.sep)
|
||||
? resolvedDirectory
|
||||
: `${resolvedDirectory}${path.sep}`
|
||||
if (resolvedCandidate !== resolvedDirectory && !resolvedCandidate.startsWith(boundary)) {
|
||||
throw ApiError.badRequest('Path escapes memory directory')
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRealpath(targetPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(targetPath)
|
||||
} catch {
|
||||
return path.resolve(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
function isValidProjectId(projectId: string): boolean {
|
||||
return (
|
||||
projectId.length > 0 &&
|
||||
!projectId.includes('\0') &&
|
||||
!projectId.includes('/') &&
|
||||
!projectId.includes('\\') &&
|
||||
projectId !== '.' &&
|
||||
projectId !== '..'
|
||||
)
|
||||
}
|
||||
|
||||
function getProjectsDir(): string {
|
||||
return path.join(getClaudeConfigHomeDir(), 'projects')
|
||||
}
|
||||
|
||||
function getProjectIdForCwd(cwd: string): string {
|
||||
return sanitizePath(findCanonicalGitRoot(cwd) ?? cwd)
|
||||
}
|
||||
|
||||
async function resolveProjectLabel(projectId: string, currentCwd: string): Promise<string> {
|
||||
const currentRoot = findCanonicalGitRoot(currentCwd) ?? currentCwd
|
||||
if (sanitizePath(currentRoot) === projectId) return currentRoot
|
||||
|
||||
const sessionPath = await inferProjectPathFromSessionFiles(projectId)
|
||||
if (sessionPath) return sessionPath
|
||||
|
||||
const filesystemPath = await inferProjectPathFromExistingDirectory(projectId)
|
||||
return filesystemPath ?? unsanitizeProjectLabel(projectId)
|
||||
}
|
||||
|
||||
async function inferProjectPathFromSessionFiles(projectId: string): Promise<string | undefined> {
|
||||
const projectDir = path.join(getProjectsDir(), projectId)
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
||||
const filePath = path.join(projectDir, entry.name)
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs })
|
||||
} catch {
|
||||
// A racing delete should not hide the rest of the memory projects.
|
||||
}
|
||||
}
|
||||
|
||||
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
for (const { filePath } of sessionFiles.slice(0, PROJECT_LABEL_SESSION_SCAN_LIMIT)) {
|
||||
const head = await readFileHead(filePath, PROJECT_LABEL_HEAD_BYTES)
|
||||
const candidate =
|
||||
extractJsonStringField(head, 'cwd') ??
|
||||
extractJsonStringField(head, 'workDir') ??
|
||||
extractJsonStringField(head, 'projectPath')
|
||||
if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function readFileHead(filePath: string, bytes: number): Promise<string> {
|
||||
const handle = await fs.open(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytes)
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytes, 0)
|
||||
return buffer.subarray(0, bytesRead).toString('utf-8')
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function inferProjectPathFromExistingDirectory(projectId: string): Promise<string | undefined> {
|
||||
const roots = Array.from(new Set([
|
||||
homedir(),
|
||||
process.env.HOME,
|
||||
process.env.USERPROFILE,
|
||||
'/private/tmp',
|
||||
'/tmp',
|
||||
].filter((root): root is string => Boolean(root && path.isAbsolute(root)))))
|
||||
|
||||
for (const root of roots) {
|
||||
const resolvedRoot = path.resolve(root)
|
||||
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(resolvedRoot))) continue
|
||||
const state = { visited: 0 }
|
||||
const match = await findDirectoryBySanitizedPath(projectId, resolvedRoot, 0, state)
|
||||
if (match) return match.normalize('NFC')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function findDirectoryBySanitizedPath(
|
||||
projectId: string,
|
||||
candidate: string,
|
||||
depth: number,
|
||||
state: { visited: number },
|
||||
): Promise<string | undefined> {
|
||||
if (state.visited >= PROJECT_LABEL_FS_SEARCH_NODE_LIMIT) return undefined
|
||||
state.visited += 1
|
||||
|
||||
const candidateId = sanitizePath(candidate)
|
||||
if (candidateId === projectId) return candidate
|
||||
if (depth >= PROJECT_LABEL_FS_SEARCH_DEPTH || !sanitizedPrefixCanMatch(projectId, candidateId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(candidate, { withFileTypes: true })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
const child = path.join(candidate, entry.name)
|
||||
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(child))) continue
|
||||
if (entry.isSymbolicLink() && !(await directoryExists(child))) continue
|
||||
const match = await findDirectoryBySanitizedPath(projectId, child, depth + 1, state)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function sanitizedPrefixCanMatch(projectId: string, prefix: string): boolean {
|
||||
if (projectId === prefix) return true
|
||||
return prefix.endsWith('-')
|
||||
? projectId.startsWith(prefix)
|
||||
: projectId.startsWith(`${prefix}-`)
|
||||
}
|
||||
|
||||
function unsanitizeProjectLabel(projectId: string): string {
|
||||
return projectId.replace(/^-/, '/').replace(/-/g, '/')
|
||||
}
|
||||
|
||||
async function directoryExists(dir: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(dir)
|
||||
return stat.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function countMarkdownFiles(dir: string): Promise<number> {
|
||||
let count = 0
|
||||
async function walk(current: string): Promise<void> {
|
||||
if (count >= MAX_MEMORY_FILES) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(current, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (count >= MAX_MEMORY_FILES) break
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const fullPath = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dir)
|
||||
return count
|
||||
}
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed(method: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
@ -25,6 +25,7 @@ import { handleDoctorApi } from './api/doctor.js'
|
||||
import { handleH5AccessApi } from './api/h5-access.js'
|
||||
import { handleActivityStatsApi } from './api/activityStats.js'
|
||||
import { handleOpenTargetsApi } from './api/open-targets.js'
|
||||
import { handleMemoryApi } from './api/memory.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -111,6 +112,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'open-targets':
|
||||
return handleOpenTargetsApi(req, url, segments)
|
||||
|
||||
case 'memory':
|
||||
return handleMemoryApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
@ -884,7 +884,7 @@ async function ensureCliSessionStarted(
|
||||
}
|
||||
}
|
||||
|
||||
function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
||||
export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
||||
const streamState = getStreamState(sessionId)
|
||||
switch (cliMsg.type) {
|
||||
case 'assistant': {
|
||||
@ -1183,6 +1183,18 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
||||
}
|
||||
return messages
|
||||
}
|
||||
if (subtype === 'memory_saved') {
|
||||
return [{
|
||||
type: 'system_notification',
|
||||
subtype: 'memory_saved',
|
||||
message: cliMsg.message,
|
||||
data: {
|
||||
writtenPaths: Array.isArray(cliMsg.writtenPaths) ? cliMsg.writtenPaths : [],
|
||||
teamCount: typeof cliMsg.teamCount === 'number' ? cliMsg.teamCount : undefined,
|
||||
verb: typeof cliMsg.verb === 'string' ? cliMsg.verb : undefined,
|
||||
},
|
||||
}]
|
||||
}
|
||||
if (subtype === 'hook_started' || subtype === 'hook_response') {
|
||||
// Hook 执行中 — 不转发给前端
|
||||
return []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user