From 90780258cd1134b1da958c0ef38dc7d7c3884d78 Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:10:36 +0800 Subject: [PATCH] fix(markdown): correct image gallery rendering (#2894) --- .../components/chat/MarkdownImageGallery.tsx | 171 ++++++++++++-- .../components/chat/MarkdownRendererImpl.tsx | 89 +------- .../src/components/chat/markdown/decorate.ts | 14 ++ .../chat/markdown/markdownCore.test.ts | 111 ++++++--- .../components/chat/markdown/markdownCore.ts | 176 +++++++++----- .../chat/markdown/markdownImageAssets.test.ts | 68 ++++++ .../chat/markdown/markdownImageAssets.ts | 194 +++++++++------- .../components/chat/message/MessageBody.tsx | 4 +- .../chat/message/parts/AssistantTextPart.tsx | 3 - .../chat/message/parts/DOCUMENTATION.md | 27 ++- packages/ui/src/index.css | 6 - packages/vscode/src/DOCUMENTATION.md | 1 + .../src/bridge-localfs-proxy-runtime.test.js | 8 + .../src/bridge-localfs-proxy-runtime.ts | 3 + .../markdown-image-grants/DOCUMENTATION.md | 30 +++ .../lib/markdown-image-grants/routes.js | 215 ++++++++++++++++++ .../lib/markdown-image-grants/routes.test.js | 175 ++++++++++++++ .../lib/opencode/feature-routes-runtime.js | 11 + 18 files changed, 1022 insertions(+), 284 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/markdownImageAssets.test.ts create mode 100644 packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md create mode 100644 packages/web/server/lib/markdown-image-grants/routes.js create mode 100644 packages/web/server/lib/markdown-image-grants/routes.test.js diff --git a/packages/ui/src/components/chat/MarkdownImageGallery.tsx b/packages/ui/src/components/chat/MarkdownImageGallery.tsx index faffc569..ba529d95 100644 --- a/packages/ui/src/components/chat/MarkdownImageGallery.tsx +++ b/packages/ui/src/components/chat/MarkdownImageGallery.tsx @@ -1,25 +1,81 @@ import React from 'react'; +import { toast } from 'sonner'; import { Icon } from '@/components/icon/Icon'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useI18n } from '@/lib/i18n'; +import { + acquireRuntimeUrlAuthToken, + refreshRuntimeUrlAuthToken, + subscribeRuntimeUrlAuthToken, +} from '@/lib/runtime-auth'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import type { ToolPopupContent } from './message/types'; import { extractMarkdownImageCandidates, MAX_MARKDOWN_IMAGE_COUNT, type MarkdownImageCandidate, } from './markdown/markdownCore'; -import { resolveMarkdownImageSource } from './markdown/markdownImageAssets'; +import { + getPreparedMarkdownImageUrl, + isLocalMarkdownImageSource, + prepareLocalMarkdownImages, + resolveMarkdownImageSource, + type PreparedMarkdownImage, +} from './markdown/markdownImageAssets'; + +const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => { + const [ready, setReady] = React.useState(false); + const [nonce, setNonce] = React.useState(0); + const apiBaseUrl = getRuntimeApiBaseUrl(); + + React.useEffect(() => { + if (!enabled) { + setReady(false); + return; + } + let cancelled = false; + let retryTimer: ReturnType | undefined; + const release = acquireRuntimeUrlAuthToken(apiBaseUrl); + const unsubscribe = subscribeRuntimeUrlAuthToken(() => { + if (!cancelled) setNonce((current) => current + 1); + }); + const refresh = () => { + void refreshRuntimeUrlAuthToken(apiBaseUrl) + .then(() => { + if (!cancelled) setReady(true); + }) + .catch(() => { + if (!cancelled) retryTimer = setTimeout(refresh, 1000); + }); + }; + refresh(); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + release(); + unsubscribe(); + }; + }, [apiBaseUrl, enabled]); + + return { ready: !enabled || ready, nonce }; +}; const MarkdownImageThumbnail: React.FC<{ candidate: MarkdownImageCandidate; + preparation?: PreparedMarkdownImage; directory: string; + assetAuthReady: boolean; + assetAuthNonce: number; onShowPopup?: (content: ToolPopupContent) => void; -}> = ({ candidate, directory, onShowPopup }) => { +}> = ({ candidate, preparation, directory, assetAuthReady, assetAuthNonce, onShowPopup }) => { + const { t } = useI18n(); const thumbnailRef = React.useRef(null); const [shouldLoad, setShouldLoad] = React.useState(false); - const [image, setImage] = React.useState<{ - url: string; - status: 'loading' | 'ready' | 'error'; - }>({ url: '', status: 'loading' }); + const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({ + url: '', + status: 'loading', + }); + const local = isLocalMarkdownImageSource(candidate.source); React.useEffect(() => { const thumbnail = thumbnailRef.current; @@ -28,7 +84,6 @@ const MarkdownImageThumbnail: React.FC<{ setShouldLoad(true); return; } - const observer = new IntersectionObserver((entries) => { if (!entries.some((entry) => entry.isIntersecting)) return; setShouldLoad(true); @@ -39,20 +94,33 @@ const MarkdownImageThumbnail: React.FC<{ }, [shouldLoad]); React.useEffect(() => { - if (!shouldLoad) return; + if (!shouldLoad || (local && !preparation)) return; + if (local) { + if (preparation?.status !== 'ready') { + setImage({ url: '', status: 'error' }); + return; + } + if (!assetAuthReady) return; + setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' }); + return; + } const controller = new AbortController(); setImage({ url: '', status: 'loading' }); - void resolveMarkdownImageSource(candidate.source, directory, controller.signal) - .then((url) => { - if (!controller.signal.aborted) setImage({ url, status: 'loading' }); - }) - .catch(() => { - if (!controller.signal.aborted) setImage({ url: '', status: 'error' }); - }); + void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => { + if (controller.signal.aborted) return; + setImage({ url, status: 'loading' }); + }).catch(() => { + if (controller.signal.aborted) return; + setImage({ url: '', status: 'error' }); + }); return () => controller.abort(); - }, [candidate.source, directory, shouldLoad]); + }, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad]); const openPreview = React.useCallback(() => { + if (image.status === 'error') { + toast.error(t('filesView.error.previewUnavailable')); + return; + } if (image.status !== 'ready' || !onShowPopup) return; onShowPopup({ open: true, @@ -61,7 +129,7 @@ const MarkdownImageThumbnail: React.FC<{ metadata: { tool: 'markdown-image-preview', filename: candidate.filename }, image: { url: image.url, filename: candidate.filename }, }); - }, [candidate.filename, image, onShowPopup]); + }, [candidate.filename, image, onShowPopup, t]); return (