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)]' }`} >