fix(desktop): render video/audio image-markdown as media, not broken <img>

Generated media often arrives as image markdown (![clip](clip.mp4)). The chat
markdown renderer maps the img element to MarkdownImage, which renders a raw
<img>; for a video/audio source the browser cannot paint it and shows a
broken-image icon even though the file is valid and plays fine. Images worked
only because <img src=...png> is valid markup.

Route video/audio sources in MarkdownImage to the existing MediaAttachment,
which already picks the correct <video>/<audio> element (streaming protocol +
open-externally fallback) by media kind. The intended MEDIA:-tag -> #media:
link path is unaffected; this only fixes the bare image-markdown case.

Since #57944 the image path is built on hooks (async src resolution, failure
and loading states), so the routing check cannot be a plain early return
inside it -- it would have to sit after every hook call and would still fire
an image resolve for media never rendered as an image. MarkdownImage is
therefore a thin hookless router in front of MarkdownImageContent, which is
the previous body unchanged.

The regression tests join the existing markdown-text.media.test.tsx added by
#57944 rather than replacing it.

Fixes #40896

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vU45SEvcxLkVeyyEE9gJ2
This commit is contained in:
Max Hsu 2026-07-27 12:26:58 +08:00 committed by Teknium
parent 3e47efeb25
commit 181511473e
2 changed files with 54 additions and 3 deletions

View file

@ -1,9 +1,9 @@
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
import { MarkdownImage, MarkdownTextContent } from './markdown-text'
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
@ -50,3 +50,32 @@ describe('MarkdownTextContent remote images', () => {
})
})
})
// Regression for #40896: generated media often arrives as image markdown
// (`![clip](clip.mp4)`). A raw <img> with a video/audio source paints a
// broken-image icon even though the file is valid, so MarkdownImage must route
// video/audio sources to the proper <video>/<audio> element.
describe('MarkdownImage media routing', () => {
afterEach(cleanup)
it('renders a <video> (not a broken <img>) for a video source', async () => {
const { container } = render(<MarkdownImage alt="clip" src="file:///tmp/clip.mp4" />)
await waitFor(() => expect(container.querySelector('video')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('renders an <audio> element for an audio source', async () => {
const { container } = render(<MarkdownImage alt="note" src="file:///tmp/note.mp3" />)
await waitFor(() => expect(container.querySelector('audio')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('still renders an <img> for an image source', () => {
const { container } = render(<MarkdownImage alt="pic" src="file:///tmp/pic.png" />)
expect(container.querySelector('video')).toBeNull()
expect(container.querySelector('audio')).toBeNull()
})
})

View file

@ -303,7 +303,29 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
// Generated/inline media often arrives as image markdown — `![clip](clip.mp4)`.
// A raw <img> with a video/audio source renders a broken-image icon (the file is
// valid, the browser just can't paint it as an image), so route those sources to
// MediaAttachment, which picks the right <video>/<audio> element (streaming
// protocol + open-externally fallback) by media kind. Detection is
// extension-based via mediaKind(); an extension-less/data/blob video URL still
// resolves to 'file' and falls through to the image path as before.
//
// This is split from the image path because that path is built on hooks: a
// conditional return inside it would have to sit after every hook call, which
// would still fire an image resolve for media we never render as an image.
export function MarkdownImage(props: ComponentProps<'img'>) {
const rawSrc = typeof props.src === 'string' ? props.src : ''
const kind = rawSrc ? mediaKind(rawSrc) : 'file'
if (kind === 'video' || kind === 'audio') {
return <MediaAttachment path={rawSrc} />
}
return <MarkdownImageContent {...props} />
}
function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<'img'>) {
const rawSrc = typeof src === 'string' ? src : ''
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
const [failed, setFailed] = useState(false)