mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23: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', () => ({
|
vi.mock('../components/markdown/MarkdownRenderer', () => ({
|
||||||
MarkdownRenderer: ({ content }: { content: string }) => (
|
MarkdownRenderer: ({ content, onLinkClick }: { content: string; onLinkClick?: (href: string) => boolean | void }) => {
|
||||||
<div data-testid="markdown-preview">{content}</div>
|
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', () => {
|
describe('MemorySettings', () => {
|
||||||
@ -241,6 +260,51 @@ describe('MemorySettings', () => {
|
|||||||
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
|
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 () => {
|
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
|
||||||
memoryApiMock.readFile.mockResolvedValue({
|
memoryApiMock.readFile.mockResolvedValue({
|
||||||
file: {
|
file: {
|
||||||
|
|||||||
@ -127,6 +127,23 @@ describe('MarkdownRenderer', () => {
|
|||||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
|
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 () => {
|
it('copies enhanced markdown button text with the legacy clipboard fallback', async () => {
|
||||||
const originalClipboard = navigator.clipboard
|
const originalClipboard = navigator.clipboard
|
||||||
const originalExecCommand = document.execCommand
|
const originalExecCommand = document.execCommand
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useCallback } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { marked, type Tokens } from 'marked'
|
import { marked, type Tokens } from 'marked'
|
||||||
import { CodeViewer } from '../chat/CodeViewer'
|
import { CodeViewer } from '../chat/CodeViewer'
|
||||||
@ -9,6 +10,7 @@ type Props = {
|
|||||||
content: string
|
content: string
|
||||||
variant?: 'default' | 'document' | 'compact'
|
variant?: 'default' | 'document' | 'compact'
|
||||||
className?: string
|
className?: string
|
||||||
|
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodeBlock = {
|
type CodeBlock = {
|
||||||
@ -159,7 +161,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
|
|||||||
.join(' ')
|
.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 { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content])
|
||||||
const proseClasses = useMemo(
|
const proseClasses = useMemo(
|
||||||
() => getProseClasses(variant, className),
|
() => getProseClasses(variant, className),
|
||||||
@ -194,10 +196,20 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
|||||||
return result
|
return result
|
||||||
}, [html, codeBlocks])
|
}, [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 target = event.target as HTMLElement | null
|
||||||
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
|
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')
|
const text = button.getAttribute('data-copy-code')
|
||||||
if (!text) return
|
if (!text) return
|
||||||
@ -210,7 +222,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
button.textContent = original
|
button.textContent = original
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}, [])
|
}, [onLinkClick])
|
||||||
|
|
||||||
if (codeBlocks.length === 0) {
|
if (codeBlocks.length === 0) {
|
||||||
const cleanHtml = enhanceMarkdownHtml(html)
|
const cleanHtml = enhanceMarkdownHtml(html)
|
||||||
|
|||||||
@ -138,6 +138,19 @@ export function MemorySettings() {
|
|||||||
void openFile(selectedProjectId, file.path)
|
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) => {
|
const toggleFolder = (path: string) => {
|
||||||
setCollapsedFolders((previous) => {
|
setCollapsedFolders((previous) => {
|
||||||
const next = new Set(previous)
|
const next = new Set(previous)
|
||||||
@ -313,7 +326,11 @@ export function MemorySettings() {
|
|||||||
<span>{t('settings.memory.rendered')}</span>
|
<span>{t('settings.memory.rendered')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<MarkdownRenderer content={previewContent || ' '} variant="document" />
|
<MarkdownRenderer
|
||||||
|
content={previewContent || ' '}
|
||||||
|
variant="document"
|
||||||
|
onLinkClick={handlePreviewLinkClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -441,7 +458,7 @@ function ProjectTreeRow({
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const display = projectDisplayName(project.label)
|
const display = projectDisplayName(project.label)
|
||||||
return (
|
return (
|
||||||
<div className="mb-0.5">
|
<div className="mb-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="memory-project-row"
|
data-testid="memory-project-row"
|
||||||
@ -449,9 +466,9 @@ function ProjectTreeRow({
|
|||||||
title={project.label}
|
title={project.label}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-label={t('settings.memory.toggleFolder', { name: display })}
|
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
|
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)]'
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -463,7 +480,7 @@ function ProjectTreeRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded ? (
|
{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 ? (
|
{loading ? (
|
||||||
<div className="px-2 py-1.5 text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
<div className="px-2 py-1.5 text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
||||||
) : fileTree.length === 0 ? (
|
) : fileTree.length === 0 ? (
|
||||||
@ -504,10 +521,10 @@ function FileRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
|
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
|
active
|
||||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
? 'border-[var(--color-memory-border)] 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-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" />
|
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||||
@ -553,7 +570,7 @@ function MemoryTreeRow({
|
|||||||
onClick={() => onToggleFolder(node.path)}
|
onClick={() => onToggleFolder(node.path)}
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
aria-label={t('settings.memory.toggleFolder', { name: node.name })}
|
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` }}
|
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <ChevronRight size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
|
{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>
|
<span className="min-w-0 flex-1 truncate font-medium">{node.name}</span>
|
||||||
</button>
|
</button>
|
||||||
{!isCollapsed ? (
|
{!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) => (
|
{node.children.map((child) => (
|
||||||
<MemoryTreeRow
|
<MemoryTreeRow
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@ -666,6 +683,70 @@ function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string
|
|||||||
return null
|
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 =
|
type MemoryTreeNode =
|
||||||
| {
|
| {
|
||||||
kind: 'folder'
|
kind: 'folder'
|
||||||
|
|||||||
@ -81,9 +81,26 @@ describe('ConversationService', () => {
|
|||||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('test-model')
|
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_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()
|
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 () => {
|
test('strips inherited provider env when desktop provider config exists', async () => {
|
||||||
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
||||||
await fs.mkdir(ccHahaDir, { recursive: true })
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||||
@ -372,3 +389,7 @@ describe('ConversationService', () => {
|
|||||||
expect(args).toContain('feature/rail')
|
expect(args).toContain('feature/rail')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sanitizeMemoryPath(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9]/g, '-')
|
||||||
|
}
|
||||||
|
|||||||
@ -22,11 +22,15 @@ import {
|
|||||||
buildClaudeCliArgs,
|
buildClaudeCliArgs,
|
||||||
resolveClaudeCliLauncher,
|
resolveClaudeCliLauncher,
|
||||||
} from '../../utils/desktopBundledCli.js'
|
} 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_PROCESS_LINES = 80
|
||||||
const MAX_CAPTURED_SDK_MESSAGES = 40
|
const MAX_CAPTURED_SDK_MESSAGES = 40
|
||||||
const MAX_CAPTURED_SDK_SUMMARY = 20
|
const MAX_CAPTURED_SDK_SUMMARY = 20
|
||||||
const CONTROL_READY_POLL_MS = 50
|
const CONTROL_READY_POLL_MS = 50
|
||||||
|
const AUTO_MEMORY_DIRNAME = 'memory'
|
||||||
|
|
||||||
type AttachmentRef = {
|
type AttachmentRef = {
|
||||||
type: 'file' | 'image'
|
type: 'file' | 'image'
|
||||||
@ -900,6 +904,7 @@ export class ConversationService {
|
|||||||
CLAUDE_CODE_ENABLE_TASKS: '1',
|
CLAUDE_CODE_ENABLE_TASKS: '1',
|
||||||
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1',
|
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1',
|
||||||
CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath,
|
CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath,
|
||||||
|
CLAUDE_COWORK_MEMORY_PATH_OVERRIDE: this.resolveDesktopAutoMemoryPath(workDir),
|
||||||
CALLER_DIR: workDir,
|
CALLER_DIR: workDir,
|
||||||
PWD: workDir,
|
PWD: workDir,
|
||||||
...(sdkUrl
|
...(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:
|
* 官方模式下构造 CLI 子进程的 auth env:
|
||||||
* - CLAUDE_CODE_ENTRYPOINT=claude-desktop 让 CLI 忽略外部残留 ANTHROPIC_* env
|
* - CLAUDE_CODE_ENTRYPOINT=claude-desktop 让 CLI 忽略外部残留 ANTHROPIC_* env
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user