Keep desktop memory paths aligned with runtime

Desktop memory settings already resolves project memory through the current sanitized project directory, while spawned CLI sessions could compute an older path variant and fail to read indexed memory files. Pin the child runtime to the same memory directory and let memory preview links open related markdown files directly from the rendered panel.

Constraint: Existing memory files may already live under both legacy underscore and current hyphenated project directories.
Rejected: Symlink or merge memory directories | it could mix stale legacy memory entries into the active project index.
Confidence: high
Scope-risk: moderate
Directive: Keep Settings memory discovery and spawned CLI memory context on the same project identity before changing either sanitizer.
Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/memory.test.ts
Tested: bun run check:server
Tested: cd desktop && bun run test -- MemorySettings MarkdownRenderer
Tested: cd desktop && bun run build
Tested: Browser smoke on local Vite/server for memory preview link navigation
Not-tested: bun run check:desktop still has pre-existing vite-config color-mix guard failure in desktop/src/theme/globals.css
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 19:34:16 +08:00
parent 4e969475f2
commit 02f23d6e3b
6 changed files with 231 additions and 17 deletions

View File

@ -22,9 +22,28 @@ vi.mock('../api/memory', () => ({
}))
vi.mock('../components/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-preview">{content}</div>
),
MarkdownRenderer: ({ content, onLinkClick }: { content: string; onLinkClick?: (href: string) => boolean | void }) => {
const match = content.match(/\[([^\]]+)\]\(([^)]+)\)/)
const linkText = match?.[1]
const linkHref = match?.[2]
return (
<div data-testid="markdown-preview">
{content}
{linkText && linkHref ? (
<a
href={linkHref}
onClick={(event) => {
if (onLinkClick?.(linkHref)) {
event.preventDefault()
}
}}
>
{linkText}
</a>
) : null}
</div>
)
},
}))
describe('MemorySettings', () => {
@ -241,6 +260,51 @@ describe('MemorySettings', () => {
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
})
it('opens linked memory markdown files from the rendered preview', async () => {
memoryApiMock.listFiles.mockResolvedValue({
files: [
{
path: 'MEMORY.md',
name: 'MEMORY.md',
title: 'MEMORY.md',
bytes: 48,
updatedAt: '2026-05-01T00:00:00.000Z',
type: 'project',
description: 'Project conventions.',
isIndex: true,
},
{
path: 'notes/manual.md',
name: 'manual.md',
title: 'Manual',
bytes: 24,
updatedAt: '2026-05-01T00:02:00.000Z',
type: 'guidance',
isIndex: false,
},
],
})
memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({
file: {
path,
content: path === 'notes/manual.md'
? '# Manual\n'
: '# Project Memory\n\n- [Manual](notes/manual.md)\n',
updatedAt: '2026-05-01T00:00:00.000Z',
bytes: 48,
},
}))
render(<MemorySettings />)
fireEvent.click(await screen.findByRole('link', { name: 'Manual' }))
await waitFor(() => {
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
})
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
})
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
memoryApiMock.readFile.mockResolvedValue({
file: {

View File

@ -127,6 +127,23 @@ describe('MarkdownRenderer', () => {
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
})
it('lets callers intercept markdown link clicks', () => {
const onLinkClick = vi.fn().mockReturnValue(true)
render(
<MarkdownRenderer
content={'[Manual](notes/manual.md)'}
onLinkClick={onLinkClick}
/>,
)
fireEvent.click(screen.getByRole('link', { name: 'Manual' }))
expect(onLinkClick).toHaveBeenCalledWith(
'notes/manual.md',
expect.objectContaining({ type: 'click' }),
)
})
it('copies enhanced markdown button text with the legacy clipboard fallback', async () => {
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand

View File

@ -1,4 +1,5 @@
import { useMemo, useCallback } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import DOMPurify from 'dompurify'
import { marked, type Tokens } from 'marked'
import { CodeViewer } from '../chat/CodeViewer'
@ -9,6 +10,7 @@ type Props = {
content: string
variant?: 'default' | 'document' | 'compact'
className?: string
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
}
type CodeBlock = {
@ -159,7 +161,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
.join(' ')
}
export function MarkdownRenderer({ content, variant = 'default', className }: Props) {
export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) {
const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content])
const proseClasses = useMemo(
() => getProseClasses(variant, className),
@ -194,10 +196,20 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
return result
}, [html, codeBlocks])
const handleClick = useCallback(async (event: React.MouseEvent<HTMLDivElement>) => {
const handleClick = useCallback(async (event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
if (!button) return
if (!button) {
const link = target?.closest<HTMLAnchorElement>('a[href]')
if (!link || !onLinkClick) return
const handled = onLinkClick(link.getAttribute('href') ?? '', event)
if (handled) {
event.preventDefault()
event.stopPropagation()
}
return
}
const text = button.getAttribute('data-copy-code')
if (!text) return
@ -210,7 +222,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
window.setTimeout(() => {
button.textContent = original
}, 1500)
}, [])
}, [onLinkClick])
if (codeBlocks.length === 0) {
const cleanHtml = enhanceMarkdownHtml(html)

View File

@ -138,6 +138,19 @@ export function MemorySettings() {
void openFile(selectedProjectId, file.path)
}
const handlePreviewLinkClick = (href: string): boolean => {
if (!selectedProjectId || !selectedFile) return false
const targetPath = resolveMarkdownMemoryLink(
href,
selectedFile.path,
selectedProject?.memoryDir,
files,
)
if (!targetPath || targetPath === selectedFile.path) return false
void openFile(selectedProjectId, targetPath)
return true
}
const toggleFolder = (path: string) => {
setCollapsedFolders((previous) => {
const next = new Set(previous)
@ -313,7 +326,11 @@ export function MemorySettings() {
<span>{t('settings.memory.rendered')}</span>
</div>
<div className="p-6">
<MarkdownRenderer content={previewContent || ' '} variant="document" />
<MarkdownRenderer
content={previewContent || ' '}
variant="document"
onLinkClick={handlePreviewLinkClick}
/>
</div>
</div>
</div>
@ -441,7 +458,7 @@ function ProjectTreeRow({
const t = useTranslation()
const display = projectDisplayName(project.label)
return (
<div className="mb-0.5">
<div className="mb-1">
<button
type="button"
data-testid="memory-project-row"
@ -449,9 +466,9 @@ function ProjectTreeRow({
title={project.label}
aria-expanded={expanded}
aria-label={t('settings.memory.toggleFolder', { name: display })}
className={`group flex min-h-9 w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
className={`group flex min-h-9 w-full items-center gap-2 rounded-md px-2.5 py-1.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)]'
? 'bg-[var(--color-memory-surface)] text-[var(--color-text-primary)] ring-1 ring-inset ring-[var(--color-memory-border)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
@ -463,7 +480,7 @@ function ProjectTreeRow({
</button>
{expanded ? (
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
<div className="ml-[18px] mt-1.5 border-l border-[var(--color-border)] pl-2.5">
{loading ? (
<div className="px-2 py-1.5 text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : fileTree.length === 0 ? (
@ -504,10 +521,10 @@ function FileRow({
type="button"
onClick={onSelect}
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
className={`mb-0.5 flex min-h-8 w-full items-center gap-1.5 rounded-sm py-1 pr-2 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
className={`mb-1 flex min-h-8 w-full items-center gap-1.5 rounded-md border py-1 pr-2 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)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
? 'border-[var(--color-memory-border)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
@ -553,7 +570,7 @@ function MemoryTreeRow({
onClick={() => onToggleFolder(node.path)}
aria-expanded={!isCollapsed}
aria-label={t('settings.memory.toggleFolder', { name: node.name })}
className="mb-0.5 flex min-h-8 w-full items-center gap-1.5 rounded-sm py-1 pr-2 text-left text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="mb-1 flex min-h-8 w-full items-center gap-1.5 rounded-md border border-transparent py-1 pr-2 text-left text-sm text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
>
{isCollapsed ? <ChevronRight size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
@ -561,7 +578,7 @@ function MemoryTreeRow({
<span className="min-w-0 flex-1 truncate font-medium">{node.name}</span>
</button>
{!isCollapsed ? (
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
<div className="ml-[18px] mt-1 border-l border-[var(--color-border)] pl-2.5">
{node.children.map((child) => (
<MemoryTreeRow
key={child.id}
@ -666,6 +683,70 @@ function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string
return null
}
function resolveMarkdownMemoryLink(
href: string,
currentPath: string,
projectMemoryDir: string | undefined,
files: MemoryFile[],
): string | null {
const rawHref = safeDecodeUriComponent(href.trim())
if (!rawHref || rawHref.startsWith('#')) return null
let target = rawHref
try {
const url = new URL(rawHref)
if (url.protocol !== 'file:') return null
target = url.pathname
} catch {
if (/^[a-z][a-z\d+.-]*:/i.test(rawHref)) return null
}
target = stripMarkdownLinkSuffix(target)
if (!target || !target.endsWith('.md')) return null
const absoluteTarget = normalizeFsPath(target)
const memoryDir = projectMemoryDir ? normalizeFsPath(projectMemoryDir) : ''
if (memoryDir) {
if (absoluteTarget === memoryDir) return DEFAULT_MEMORY_PATH
if (absoluteTarget.startsWith(`${memoryDir}/`)) {
return findMemoryFileByPath(files, absoluteTarget.slice(memoryDir.length + 1))
}
}
if (target.startsWith('/')) return null
const currentParts = currentPath.split('/').filter(Boolean)
const baseParts = currentParts.slice(0, -1)
const resolvedParts: string[] = []
for (const part of [...baseParts, ...target.split('/')]) {
if (!part || part === '.') continue
if (part === '..') {
resolvedParts.pop()
continue
}
resolvedParts.push(part)
}
return findMemoryFileByPath(files, resolvedParts.join('/'))
}
function safeDecodeUriComponent(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function stripMarkdownLinkSuffix(value: string): string {
return value.split('#')[0]?.split('?')[0]?.trim() ?? ''
}
function findMemoryFileByPath(files: MemoryFile[], path: string): string | null {
const normalized = normalizeFsPath(path)
return files.find((file) => normalizeFsPath(file.path) === normalized)?.path ?? null
}
type MemoryTreeNode =
| {
kind: 'folder'

View File

@ -81,9 +81,26 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
expect(env.ANTHROPIC_MODEL).toBe('test-model')
expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'))
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toBe(
`${path.join(tmpDir, 'projects', 'D--workspace-code-myself-code-cc-haha', 'memory')}${path.sep}`,
)
await expect(fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))).resolves.toBeTruthy()
})
test('buildChildEnv pins desktop memory to the current sanitized project directory', async () => {
const service = new ConversationService() as any
const workDir = path.join(tmpDir, 'workspace', 'myself_code', 'claude-code-haha')
await fs.mkdir(workDir, { recursive: true })
const env = (await service.buildChildEnv(workDir)) as Record<string, string>
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toBe(
`${path.join(tmpDir, 'projects', sanitizeMemoryPath(workDir), 'memory')}${path.sep}`,
)
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toContain('myself-code')
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).not.toContain('myself_code')
})
test('strips inherited provider env when desktop provider config exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
@ -372,3 +389,7 @@ describe('ConversationService', () => {
expect(args).toContain('feature/rail')
})
})
function sanitizeMemoryPath(value: string): string {
return value.replace(/[^a-zA-Z0-9]/g, '-')
}

View File

@ -22,11 +22,15 @@ import {
buildClaudeCliArgs,
resolveClaudeCliLauncher,
} from '../../utils/desktopBundledCli.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { sanitizePath } from '../../utils/path.js'
const MAX_CAPTURED_PROCESS_LINES = 80
const MAX_CAPTURED_SDK_MESSAGES = 40
const MAX_CAPTURED_SDK_SUMMARY = 20
const CONTROL_READY_POLL_MS = 50
const AUTO_MEMORY_DIRNAME = 'memory'
type AttachmentRef = {
type: 'file' | 'image'
@ -900,6 +904,7 @@ export class ConversationService {
CLAUDE_CODE_ENABLE_TASKS: '1',
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1',
CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath,
CLAUDE_COWORK_MEMORY_PATH_OVERRIDE: this.resolveDesktopAutoMemoryPath(workDir),
CALLER_DIR: workDir,
PWD: workDir,
...(sdkUrl
@ -933,6 +938,20 @@ export class ConversationService {
}
}
private resolveDesktopAutoMemoryPath(workDir: string): string {
const memoryProjectRoot = fs.existsSync(workDir)
? findCanonicalGitRoot(workDir) ?? workDir
: workDir
return (
path.join(
getClaudeConfigHomeDir(),
'projects',
sanitizePath(memoryProjectRoot),
AUTO_MEMORY_DIRNAME,
) + path.sep
).normalize('NFC')
}
/**
* CLI auth env:
* - CLAUDE_CODE_ENTRYPOINT=claude-desktop CLI ANTHROPIC_* env