From 02f23d6e3bc36f2932d0a886782344f747e8d654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 19:34:16 +0800 Subject: [PATCH] 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 --- desktop/src/__tests__/memorySettings.test.tsx | 70 +++++++++++- .../markdown/MarkdownRenderer.test.tsx | 17 +++ .../components/markdown/MarkdownRenderer.tsx | 20 +++- desktop/src/pages/MemorySettings.tsx | 101 ++++++++++++++++-- .../__tests__/conversation-service.test.ts | 21 ++++ src/server/services/conversationService.ts | 19 ++++ 6 files changed, 231 insertions(+), 17 deletions(-) diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index eb544f27..ff2f0be4 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -22,9 +22,28 @@ vi.mock('../api/memory', () => ({ })) vi.mock('../components/markdown/MarkdownRenderer', () => ({ - MarkdownRenderer: ({ content }: { content: string }) => ( -
{content}
- ), + MarkdownRenderer: ({ content, onLinkClick }: { content: string; onLinkClick?: (href: string) => boolean | void }) => { + const match = content.match(/\[([^\]]+)\]\(([^)]+)\)/) + const linkText = match?.[1] + const linkHref = match?.[2] + return ( +
+ {content} + {linkText && linkHref ? ( + { + if (onLinkClick?.(linkHref)) { + event.preventDefault() + } + }} + > + {linkText} + + ) : null} +
+ ) + }, })) 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() + + 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: { diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index 4692a7ff..832e188a 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -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( + , + ) + + 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 diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index eac69a50..a8fe70b3 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -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) => 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) => { + const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null const button = target?.closest('[data-copy-code]') - if (!button) return + if (!button) { + const link = target?.closest('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) diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index 7107f8ac..d09b4b3f 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -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() { {t('settings.memory.rendered')}
- +
@@ -441,7 +458,7 @@ function ProjectTreeRow({ const t = useTranslation() const display = projectDisplayName(project.label) return ( -
+
{expanded ? ( -
+
{loading ? (
{t('common.loading')}
) : 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)]' }`} >