diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx
index 70011ab..9f25286 100644
--- a/src/client/MarkdownHtml.tsx
+++ b/src/client/MarkdownHtml.tsx
@@ -10,9 +10,10 @@
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
- * open in a new tab with noopener, and local media `src` attributes are
- * rewritten through the session-scoped `/sidebar/file` media route — the same
- * trust fence the markdown image rewriter (`markdown-images.ts`) uses.
+ * open in a new tab with noopener, and local media goes through the
+ * session-scoped `/sidebar/file` media route — markdown-syntax image
+ * destinations are rewritten via `markdown-images.ts` and `src` attributes
+ * through `resolveLocalMediaDest` (the same trust fence).
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
@@ -21,7 +22,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentType } from 'react'
import { markdownTextProps } from './markdown-labels.tsx'
import { lazyChunkComponent } from './lazy-chunk.tsx'
-import { resolveLocalMediaDest } from './markdown-images.ts'
+import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
@@ -235,7 +236,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
const prepared = useMemo(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
- const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
+ const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
+ // MarkdownText drops non-http(s) image destinations (chat-security
+ // stance), so rewrite local ones into /sidebar/file media URLs first —
+ // the same trust fence the sanitized HTML leaves below go through.
+ // Idempotent: already-absolute media URLs pass through untouched.
+ const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
return {
kind: 'markdown',
text,
diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx
index 459eaf0..865a034 100644
--- a/src/client/TextEditor.tsx
+++ b/src/client/TextEditor.tsx
@@ -346,9 +346,11 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
[markdown, mode, previewMdText],
)
- /** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
- * for documents that actually contain HTML — plain markdown keeps the
- * legacy single-pass render path below, byte-for-byte. */
+ /** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
+ * every markdown preview, so the render below always takes the split
+ * renderer — its markdown runs rewrite local image destinations internally
+ * (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
+ * pre-rewritten `previewText`) are dead in the current wiring. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
[markdown, mode, previewMdText],
diff --git a/tests/markdown-html-render.spec.tsx b/tests/markdown-html-render.spec.tsx
index 8e0ca9d..662152a 100644
--- a/tests/markdown-html-render.spec.tsx
+++ b/tests/markdown-html-render.spec.tsx
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
await unmount(root)
})
})
+
+describe('MarkdownDocument (local markdown images)', () => {
+ it('renders markdown-syntax local images through the /sidebar/file route', async () => {
+ const { container, root } = await renderDocument([
+ '# Title',
+ '',
+ '',
+ '',
+ '![logo][logo]',
+ '',
+ '[logo]: ./logo.png',
+ ].join('\n'))
+ const imgs = [...container.querySelectorAll('img')]
+ expect(imgs.length, 'both local images must render as
, not alt fallback').toBe(2)
+ expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
+ expect(imgs[0]?.getAttribute('alt')).toBe('icon')
+ expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
+ expect(imgs[1]?.getAttribute('alt')).toBe('logo')
+ await unmount(root)
+ })
+
+ it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
+ const { container, root } = await renderDocument([
+ 'badges
',
+ '',
+ '',
+ ].join('\n'))
+ const img = container.querySelector('img')
+ expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
+ expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
+ await unmount(root)
+ })
+
+ it('keeps remote image destinations untouched', async () => {
+ const { container, root } = await renderDocument('')
+ const img = container.querySelector('img')
+ expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
+ await unmount(root)
+ })
+
+ it('is idempotent for already-rewritten media URLs', async () => {
+ const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
+ const { container, root } = await renderDocument(``)
+ const img = container.querySelector('img')
+ expect(img?.getAttribute('src')).toBe(url)
+ await unmount(root)
+ })
+})