mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
4e969475f2
commit
02f23d6e3b
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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, '-')
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user