diff --git a/desktop/src/components/chat/AuthedImage.test.tsx b/desktop/src/components/chat/AuthedImage.test.tsx
index f2dc55a8..9066809e 100644
--- a/desktop/src/components/chat/AuthedImage.test.tsx
+++ b/desktop/src/components/chat/AuthedImage.test.tsx
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
+import { ApiError } from '../../api/client'
import { AuthedImage } from './AuthedImage'
beforeEach(() => {
@@ -42,6 +43,35 @@ describe('AuthedImage', () => {
await waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1))
})
+ // A file the server will not serve is not there to show; anything else is a
+ // load that should have worked.
+ it.each([
+ [404, 'unavailable'],
+ [403, 'unavailable'],
+ [400, 'unavailable'],
+ [413, 'unavailable'],
+ [401, 'failed'],
+ [500, 'failed'],
+ ] as const)('reports HTTP %i from the authenticated attempt as %s', async (status, failure) => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, {}))
+ const onFailure = vi.fn()
+ render()
+
+ fireEvent.error(screen.getByRole('img'))
+
+ await waitFor(() => expect(onFailure).toHaveBeenCalledWith(failure))
+ })
+
+ it('reports a dropped connection as a failure, not as a file that is not there', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new TypeError('Failed to fetch'))
+ const onFailure = vi.fn()
+ render()
+
+ fireEvent.error(screen.getByRole('img'))
+
+ await waitFor(() => expect(onFailure).toHaveBeenCalledWith('failed'))
+ })
+
it('does not apply a result that arrives after the image is gone, and frees it', async () => {
let finish!: (url: string) => void
fetchServerImageBlobUrl.mockReturnValue(new Promise((resolve) => { finish = resolve }))
@@ -89,6 +119,8 @@ describe('AuthedImage', () => {
fireEvent.error(screen.getByRole('img'))
fireEvent.error(screen.getByRole('img'))
expect(onFailure).toHaveBeenCalledTimes(1)
+ // The server sent the file and it did not decode: there, and broken.
+ expect(onFailure).toHaveBeenCalledWith('failed')
})
it('discards a late copy after switching sources, even when returning to the original source', async () => {
diff --git a/desktop/src/components/chat/AuthedImage.tsx b/desktop/src/components/chat/AuthedImage.tsx
index 61175ba0..912144ea 100644
--- a/desktop/src/components/chat/AuthedImage.tsx
+++ b/desktop/src/components/chat/AuthedImage.tsx
@@ -1,9 +1,9 @@
import type { ImgHTMLAttributes } from 'react'
-import { useAuthedImageFallback } from '@/lib/useAuthedImageFallback'
+import { useAuthedImageFallback, type ImageFailure } from '@/lib/useAuthedImageFallback'
type Props = Omit, 'onError'> & {
- /** Runs once the image has failed even with the app's credential. */
- onFailure?: () => void
+ /** Runs once the image has failed even with the app's credential, saying why. */
+ onFailure?: (failure: ImageFailure) => void
/** A user retry skips the potentially cached bare failure and fetches afresh. */
retryWithCredential?: boolean
}
diff --git a/desktop/src/components/chat/InlineImageGallery.test.tsx b/desktop/src/components/chat/InlineImageGallery.test.tsx
index 507db689..a878ff32 100644
--- a/desktop/src/components/chat/InlineImageGallery.test.tsx
+++ b/desktop/src/components/chat/InlineImageGallery.test.tsx
@@ -2,10 +2,12 @@ import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ApiError } from '../../api/client'
import { browserHost } from '../../lib/desktopHost/browserHost'
// getBaseUrl backs the absolute-path src (/api/filesystem/file).
-vi.mock('../../api/client', () => ({
+vi.mock('../../api/client', async (original) => ({
+ ...(await original>()),
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
@@ -14,8 +16,9 @@ vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
-// The authenticated fallback an
error falls back to. It rejects by default,
-// which is what a missing or denied file does, so the failure notice shows.
+// The authenticated fallback an
error falls back to. It rejects by default
+// the way a server fault does, so the failure notice shows; a file the server will
+// not serve is answered with its 4xx status instead.
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
@@ -29,7 +32,7 @@ import { resetDiskListingCacheForTests } from '../../hooks/useDiskConfirmedTarge
beforeEach(() => {
resetDiskListingCacheForTests()
getWorkspaceTree.mockReset().mockResolvedValue({ state: 'missing', path: '', entries: [] })
- fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('403'))
+ fetchServerImageBlobUrl.mockReset().mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
// jsdom ships no object-URL support.
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
@@ -39,37 +42,97 @@ function imgSrcs(): string[] {
}
describe('InlineImageGallery', () => {
- it('shows a failed image notice and filename instead of hiding the gallery entry', async () => {
- render()
+ it('shows a retryable notice when an image the server would serve fails to load', async () => {
+ render()
fireEvent.error(screen.getByRole('img'))
const notice = await screen.findByRole('alert')
expect(notice).toBeVisible()
expect(notice).toHaveTextContent('Unable to load image')
- expect(notice).toHaveTextContent('denied.png')
- expect(notice).toHaveTextContent('The file may be missing or access may be denied.')
+ expect(notice).toHaveTextContent('chart.png')
+ expect(notice).toHaveTextContent('The file may be damaged, or the local server did not respond.')
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
})
it('keeps other images usable and tracks failures by source when the list changes', async () => {
- const { rerender } = render()
- fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
+ const { rerender } = render()
+ fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
await screen.findByRole('alert')
- expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
- fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
+ expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: /chart.png/ }))
expect(screen.getByRole('dialog')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
- rerender()
+ rerender()
expect(screen.getByRole('img', { name: 'new.png' })).toBeVisible()
- expect(screen.getByRole('alert')).toHaveTextContent('denied.png')
- expect(screen.queryByRole('img', { name: 'denied.png' })).not.toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveTextContent('broken.png')
+ expect(screen.queryByRole('img', { name: 'broken.png' })).not.toBeInTheDocument()
})
- it('retries the same protected URL and keeps feedback if the retry fails', async () => {
- render()
+ describe('an image the server will not serve', () => {
+ // Most paths a reply names that do not load are not there at all: a file
+ // cleaned out of /tmp since, a web route, a name quoted from a log. That is
+ // no fault and no retry fixes it, yet every such reply in the history carried
+ // a red "unable to load" notice.
+ it.each([
+ [404, 'it is missing'],
+ [403, 'it is outside the readable roots'],
+ [400, 'it is not an image file'],
+ ])('leaves no trace when the server answers %i: %s', async (status) => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, { error: 'refused' }))
+ render()
+
+ fireEvent.error(screen.getByRole('img'))
+
+ await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(screen.queryByText('1 image')).not.toBeInTheDocument()
+ })
+
+ it('keeps the images that load beside one that is gone', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
+ render()
+
+ fireEvent.error(screen.getByRole('img', { name: 'gone.png' }))
+
+ await waitFor(() => expect(screen.queryByRole('img', { name: 'gone.png' })).not.toBeInTheDocument())
+ expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
+ expect(screen.getByText('1 image')).toBeInTheDocument()
+ })
+
+ it('leaves no trace for a missing workspace image the prose spelled out with its directory', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, 'not found'))
+ render(
+ ,
+ )
+
+ fireEvent.error(screen.getByRole('img'))
+
+ await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('tries again in another workspace, where the same path can exist', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
+ const { rerender } = render()
+ fireEvent.error(screen.getByRole('img'))
+ await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
+
+ rerender()
+
+ expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
+ })
+ })
+
+ it('retries the same URL and keeps feedback if the retry fails', async () => {
+ render()
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
@@ -140,10 +203,10 @@ describe('InlineImageGallery', () => {
{ sessionId: 'new-session', workDir: '/tmp/old' },
{ sessionId: 'old-session', workDir: '/tmp/new' },
])('clears a failed absolute source when context changes to %j', (context) => {
- const { rerender } = render()
+ const { rerender } = render()
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
- rerender()
+ rerender()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', source)
})
@@ -190,9 +253,10 @@ describe('InlineImageGallery', () => {
describe('an image the prose only names, without a path', () => {
// A read-only turn ("which commit swapped nodemaven_banner_sep.png?") names a
- // file it never wrote. Nothing proves it sits at the workdir root, so a failed
- // load is a wrong guess, not a broken deliverable worth a red error block.
- it('does not raise the error block when a guessed bare name fails to load', async () => {
+ // file it never wrote. Resolved at the workdir root it is not there, and a file
+ // that is not there leaves no trace.
+ it('does not raise the error block when a guessed bare name is not there', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, 'not found'))
render(
{
expect(screen.queryByText('1 image')).not.toBeInTheDocument()
})
- it('still raises the error block for a path the prose spelled out, though the checkpoint missed it', async () => {
- // A shell-rendered image never reaches changedFiles; its failure is real.
- render(
- ,
- )
-
- fireEvent.error(screen.getByRole('img'))
-
- expect(await screen.findByRole('alert')).toHaveTextContent('frame.png')
- })
-
it('shows the image the workspace really holds when a verb is glued to its name', async () => {
getWorkspaceTree.mockResolvedValue({
state: 'ok',
@@ -251,19 +299,75 @@ describe('InlineImageGallery', () => {
]))
})
- it('still raises the error block for a name the turn really wrote', async () => {
+ it('shows the notice for a guessed name too when its load fails for another reason', async () => {
+ // How the reply named a file does not decide the notice; why the load failed does.
render(
,
)
fireEvent.error(screen.getByRole('img'))
- expect(await screen.findByRole('alert')).toHaveTextContent('banner.png')
+ expect(await screen.findByRole('alert')).toHaveTextContent('nodemaven_banner_sep.png')
+ })
+ })
+
+ describe('a pattern the text quotes, not a file', () => {
+ // The reply pointed at the screenshots it had taken as a glob. No URL loads
+ // `0*.png`, so the gallery showed a red "unable to load" tile with a retry.
+ it('shows nothing for a glob quoted in the reply', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryAllByRole('img')).toHaveLength(0)
+ expect(screen.queryByText('1 image')).not.toBeInTheDocument()
+ })
+
+ it.each([
+ ['a wildcard directory listing', 'zsh: no matches found: /tmp/pres-verify/steps/*.png'],
+ ['a wildcard name', '截图在 /private/tmp/todo_*.png'],
+ ['a single-character wildcard', '帧序列 /tmp/frames/shot-??.png'],
+ ['a format-string placeholder', 'plt.savefig(f"/tmp/plots/{name}.png")'],
+ ['a template variable', 'logo 路径是 /connectors/${brand.id}.svg'],
+ ['a brace expansion', '两张图 /tmp/{before,after}.png'],
+ ['a shell variable', 'cp shot.png /tmp/$NAME.png'],
+ ['a printf frame pattern', 'ffmpeg -i in.mp4 /tmp/frames/f%03d.png'],
+ ])('shows nothing for %s', (_shape, text) => {
+ render()
+
+ expect(screen.queryAllByRole('img')).toHaveLength(0)
+ })
+
+ it('still shows the concrete file named beside the pattern', () => {
+ render(
+ ,
+ )
+
+ expect(imgSrcs()).toEqual([
+ 'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/tmp/cc-haha-ui-review/01-initial.png'),
+ ])
+ })
+
+ it('keeps a real directory whose name has brackets', () => {
+ render()
+
+ expect(imgSrcs()).toEqual([
+ 'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/w/app/blog/[slug]/opengraph-image.png'),
+ ])
})
})
diff --git a/desktop/src/components/chat/InlineImageGallery.tsx b/desktop/src/components/chat/InlineImageGallery.tsx
index 71252486..cafee46d 100644
--- a/desktop/src/components/chat/InlineImageGallery.tsx
+++ b/desktop/src/components/chat/InlineImageGallery.tsx
@@ -18,11 +18,18 @@ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
/**
* Extracts absolute image file paths from text content.
* Matches paths like /Users/.../image.png, /tmp/output.jpg, etc.
+ *
+ * Wildcards (`*`, `?`) and substitutions (`{name}`, `${id}`, `$NAME`, `%03d`)
+ * are not path characters: the character that opens one ends the match. A path
+ * like `/tmp/shots/0*.png` or `/tmp/frames/f%03d.png` names a set of files, or a
+ * template for one, that no URL can load; taken as a file, it became a red
+ * "unable to load" tile under a reply that only quoted it. Brackets stay: real
+ * directories use them (`app/[slug]/opengraph-image.png`).
*/
export function extractImagePaths(text: string): string[] {
// Match absolute paths ending with image extensions
// Handles paths that may be wrapped in backticks, quotes, or standalone
- const regex = /(?:^|[\s`"'(])(\/?(?:[A-Za-z]:[\\/]|\/)[^\s`"')<>]+\.(?:png|jpe?g|gif|webp|svg|bmp|avif|ico))/gim
+ const regex = /(?:^|[\s`"'(])(\/?(?:[A-Za-z]:[\\/]|\/)[^\s`"')<>*?{$%]+\.(?:png|jpe?g|gif|webp|svg|bmp|avif|ico))/gim
const paths: string[] = []
const seen = new Set()
@@ -58,16 +65,6 @@ type GalleryImage = {
name: string
/** Where the file is, for "open in system app". Relative until the workdir is known. */
path: string
- /**
- * The prose gave only a bare name and nothing the turn wrote corroborates where
- * the file is, so the URL is a guess. A failed load is then a wrong guess, not
- * a broken deliverable.
- */
- inferred?: boolean
-}
-
-function samePath(left: string, right: string): boolean {
- return left.replaceAll('\\', '/').toLowerCase() === right.replaceAll('\\', '/').toLowerCase()
}
type Props = {
@@ -87,12 +84,17 @@ type Props = {
export function InlineImageGallery({ text, sessionId, workDir, changedFiles, suppressManagedGeneratedImages = false }: Props) {
const t = useTranslation()
const [activeIndex, setActiveIndex] = useState(null)
- const [failureState, setFailureState] = useState(() => ({ sessionId, workDir, sources: new Set() }))
+ const [failureState, setFailureState] = useState(() => ({
+ sessionId,
+ workDir,
+ failed: new Set(),
+ unavailable: new Set(),
+ }))
// The same absolute URL can become readable in a different workspace/session.
if (failureState.sessionId !== sessionId || failureState.workDir !== workDir) {
- setFailureState({ sessionId, workDir, sources: new Set() })
+ setFailureState({ sessionId, workDir, failed: new Set(), unavailable: new Set() })
}
- const failedSources = failureState.sources
+ const failedSources = failureState.failed
const markdownImageSources = useMemo(
() => new Set(extractMarkdownImageSources(text).map(normalizeImageReference)),
@@ -153,7 +155,6 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
// Dedup: an absolute path inside the workspace can be caught by BOTH sources.
// Skip a relative target whose basename already appears among the absolute
// images, and also collapse duplicate relative targets by resolved src.
- const proseText = text.replaceAll('\\', '/')
const absoluteNames = new Set(absolute.map((img) => img.name))
const seenSrc = new Set(absolute.map((img) => img.src))
const relative: GalleryImage[] = []
@@ -171,21 +172,17 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
continue
}
seenSrc.add(src)
- const openPath = resolveAbsoluteOpenPath(relPath, workDir ?? undefined)
- const corroborated = changedFileEvidence?.some((file) => samePath(file, openPath)) ?? false
- // A path the prose spells out with its directory is a claim, even when the
- // checkpoint missed it (shell writes are invisible there); only a bare name,
- // placed at the root or in an inferred directory, is a guess.
- const namedWithDirectory = /[\\/]/.test(relPath) && proseText.includes(relPath.replaceAll('\\', '/'))
- relative.push({ src, name, path: openPath, inferred: !corroborated && !namedWithDirectory })
+ relative.push({ src, name, path: resolveAbsoluteOpenPath(relPath, workDir ?? undefined) })
}
return [...absolute, ...relative]
- }, [changedFileEvidence, imagePaths, relativeTargets, sessionId, text, workDir])
+ }, [imagePaths, relativeTargets, sessionId, workDir])
- // A guessed image that failed to load leaves no trace: there is nothing to retry
- // when the file was never claimed to be there.
- const visibleImages = images.filter((img) => !(img.inferred && failedSources.has(img.src)))
+ // A picture the server will not serve — missing, outside the readable roots, not
+ // an image — leaves no trace. The reply only named it, and its text still does:
+ // a file cleaned out of /tmp, a web route, a name quoted from a log. Only a load
+ // that should have worked is an error worth a retry.
+ const visibleImages = images.filter((img) => !failureState.unavailable.has(img.src))
if (visibleImages.length === 0) return null
return (
@@ -203,9 +200,9 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
title={t('chat.imageLoadFailed')}
retryLabel={t('common.retry')}
onRetry={() => setFailureState((previous) => {
- const sources = new Set(previous.sources)
- sources.delete(img.src)
- return { ...previous, sources }
+ const failed = new Set(previous.failed)
+ failed.delete(img.src)
+ return { ...previous, failed }
})}
detail={(
<>
@@ -227,9 +224,10 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
loading="lazy"
className="w-full object-cover"
style={{ maxHeight: visibleImages.length === 1 ? 400 : 240 }}
- // img errors expose no HTTP status: a denied, missing or invalid
- // image needs visible feedback without claiming a specific cause.
- onFailure={() => setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))}
+ onFailure={(failure) => setFailureState((previous) => ({
+ ...previous,
+ [failure]: new Set(previous[failure]).add(img.src),
+ }))}
/>
diff --git a/desktop/src/components/markdown/MarkdownHtml.test.tsx b/desktop/src/components/markdown/MarkdownHtml.test.tsx
index b00ec4f7..54115e3e 100644
--- a/desktop/src/components/markdown/MarkdownHtml.test.tsx
+++ b/desktop/src/components/markdown/MarkdownHtml.test.tsx
@@ -1,6 +1,7 @@
import '@testing-library/jest-dom'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ApiError } from '@/api/client'
import { useSettingsStore } from '@/stores/settingsStore'
import { MarkdownHtml } from './MarkdownHtml'
@@ -8,7 +9,8 @@ const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
vi.mock('@/lib/authedImage', () => ({ fetchServerImageBlobUrl }))
beforeEach(() => {
- fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('404'))
+ // A server fault: the load should have worked, so the retryable notice shows.
+ fetchServerImageBlobUrl.mockReset().mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
useSettingsStore.setState({ locale: 'en' })
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
@@ -44,26 +46,65 @@ describe('MarkdownHtml image lifecycle', () => {
})
it('reacts to a locale change while the failure is visible', async () => {
- render('} />)
+ render('} />)
fireEvent.error(screen.getByRole('img'))
await screen.findByRole('alert')
act(() => useSettingsStore.setState({ locale: 'zh' }))
expect(screen.getByRole('alert')).toHaveTextContent('无法加载图片')
- expect(screen.getByRole('button', { name: '重试图片:missing.png' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '重试图片:broken.png' })).toBeInTheDocument()
})
it('keeps the placeholder when the retry returns a body that cannot decode', async () => {
- fetchServerImageBlobUrl.mockRejectedValueOnce(new Error('404')).mockResolvedValueOnce('blob:invalid')
- const { container } = render('} />)
+ fetchServerImageBlobUrl.mockRejectedValueOnce(new ApiError(500, {})).mockResolvedValueOnce('blob:invalid')
+ const { container } = render('} />)
fireEvent.error(screen.getByRole('img'))
await screen.findByRole('alert')
fireEvent.click(screen.getByRole('button', { name: /Retry image/ }))
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:invalid'))
fireEvent.error(container.querySelector('img')!)
- expect(screen.getByRole('alert')).toHaveTextContent('missing.png')
+ expect(screen.getByRole('alert')).toHaveTextContent('broken.png')
expect(screen.getByRole('button', { name: /Retry image/ })).toBeEnabled()
})
+ describe('an image the server will not serve', () => {
+ // A picture the reply embedded that is missing or outside the readable roots
+ // is not a fault to retry: like any image that cannot be shown, it falls back
+ // to its text alternative instead of a red notice.
+ it.each([404, 403])('falls back to its description after HTTP %i', async (status) => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, { error: 'refused' }))
+ render(效果如下:
'} />)
+
+ fireEvent.error(screen.getByRole('img'))
+
+ expect(await screen.findByText('架构图')).toBeVisible()
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Retry image/ })).not.toBeInTheDocument()
+ })
+
+ it('leaves nothing in place of one without a description', async () => {
+ fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
+ const { container } = render(效果如下:
'} />)
+
+ fireEvent.error(container.querySelector('img')!)
+
+ await waitFor(() => expect(container.querySelector('img')).not.toBeVisible())
+ expect(container).toHaveTextContent(/^效果如下:$/)
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('settles on the description when a retry finds the file gone', async () => {
+ fetchServerImageBlobUrl.mockRejectedValueOnce(new ApiError(500, {}))
+ .mockRejectedValueOnce(new ApiError(404, { error: 'File not found' }))
+ render('} />)
+ fireEvent.error(screen.getByRole('img'))
+ fireEvent.click(await screen.findByRole('button', { name: /Retry image/ }))
+
+ expect(await screen.findByText('chart')).toBeVisible()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+ })
+
it('frees the old image copy and resets failures when the Markdown changes', async () => {
fetchServerImageBlobUrl.mockResolvedValue('blob:old')
const { rerender } = render('} />)
diff --git a/desktop/src/components/markdown/MarkdownHtml.tsx b/desktop/src/components/markdown/MarkdownHtml.tsx
index 18f67999..bfabfd60 100644
--- a/desktop/src/components/markdown/MarkdownHtml.tsx
+++ b/desktop/src/components/markdown/MarkdownHtml.tsx
@@ -23,10 +23,11 @@ function imageName(src: string, fallback: string): string {
function MarkdownImage(props: ImageProps) {
const t = useTranslation()
const errorId = useId()
- const [status, setStatus] = useState<'initial' | 'failed' | 'retrying'>('initial')
+ const [status, setStatus] = useState<'initial' | 'unavailable' | 'failed' | 'retrying'>('initial')
const [attempt, setAttempt] = useState(0)
const name = imageName(props.src ?? '', props.alt || t('assistantOutputs.kind.image'))
- const showError = status !== 'initial'
+ const hidden = status !== 'initial'
+ const showError = status === 'failed' || status === 'retrying'
return (
<>
@@ -34,11 +35,17 @@ function MarkdownImage(props: ImageProps) {
{...props}
key={attempt}
retryWithCredential={attempt > 0}
- hidden={showError}
- style={{ display: showError ? 'none' : undefined }}
+ hidden={hidden}
+ style={{ display: hidden ? 'none' : undefined }}
onLoad={() => setStatus('initial')}
- onFailure={() => setStatus('failed')}
+ onFailure={setStatus}
/>
+ {/* A picture the server will not serve (missing, outside the readable roots)
+ is not a fault to retry. Like any image that cannot be shown, it falls
+ back to its text alternative. */}
+ {status === 'unavailable' && props.alt && (
+ {props.alt}
+ )}
{showError && (
diff --git a/desktop/src/components/markdown/MarkdownRenderer.authedImage.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.authedImage.test.tsx
index 37aa25b3..9121c2c2 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.authedImage.test.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.authedImage.test.tsx
@@ -10,6 +10,7 @@ vi.mock('../../api/client', async (original) => ({
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
+import { ApiError } from '../../api/client'
import { MarkdownRenderer } from './MarkdownRenderer'
import { useSettingsStore } from '@/stores/settingsStore'
@@ -23,10 +24,25 @@ beforeEach(() => {
})
describe('MarkdownRenderer local images', () => {
+ // A missing or unreadable picture is no fault, and a red notice for each one —
+ // every embed of a file since cleaned out of /tmp — was noise. It falls back to
+ // its description, as an image that cannot be shown does.
+ it.each([404, 403])('shows the description instead of an error after HTTP %s', async (status) => {
+ apiGetBlob.mockRejectedValue(new ApiError(status, { error: 'refused' }))
+ const { container } = render(
+ LOCAL.replace('chart.png', 'missing.png')} />,
+ )
+ fireEvent.error(container.querySelector('img')!)
+
+ expect(await screen.findByText('description')).toBeVisible()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ })
+
// QA-002: a terminal image error used to leave only the browser's broken icon.
- it.each([404, 403])('keeps a named error placeholder after HTTP %s and lets a retry recover', async (status) => {
- apiGetBlob.mockRejectedValueOnce(new Error(String(status)))
- .mockRejectedValueOnce(new Error(String(status)))
+ it('keeps a named error placeholder after a server fault and lets a retry recover', async () => {
+ apiGetBlob.mockRejectedValueOnce(new ApiError(500, { error: 'Internal error' }))
+ .mockRejectedValueOnce(new ApiError(500, { error: 'Internal error' }))
.mockResolvedValueOnce(new Blob(['png'], { type: 'image/png' }))
const { container } = render(
LOCAL.replace('chart.png', 'missing.png')} />,
@@ -36,7 +52,7 @@ describe('MarkdownRenderer local images', () => {
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Unable to load image')
expect(alert).toHaveTextContent('missing.png')
- expect(alert).toHaveTextContent('The file may be missing or access may be denied.')
+ expect(alert).toHaveTextContent('The file may be damaged, or the local server did not respond.')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
const retry = screen.getByRole('button', { name: 'Retry image: missing.png' })
expect(retry).toHaveAttribute('aria-describedby')
@@ -56,7 +72,7 @@ describe('MarkdownRenderer local images', () => {
})
it('keeps failures local to one image and excludes it from the viewer', async () => {
- apiGetBlob.mockRejectedValue(new Error('404'))
+ apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
const onImageClick = vi.fn()
const { container } = render(
LOCAL.replace('chart.png', src)} onImageClick={onImageClick} />,
@@ -68,7 +84,7 @@ describe('MarkdownRenderer local images', () => {
})
it('does not navigate a surrounding link when retry is clicked', async () => {
- apiGetBlob.mockRejectedValue(new Error('404'))
+ apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
const onLinkClick = vi.fn()
const { container } = render(
LOCAL} onLinkClick={onLinkClick} />,
@@ -82,7 +98,7 @@ describe('MarkdownRenderer local images', () => {
})
it('does not reuse a failure from another renderer with the same parsed Markdown', async () => {
- apiGetBlob.mockRejectedValue(new Error('404'))
+ apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
const content = ''
const { container } = render(<> LOCAL} /> LOCAL} />>)
const prose = container.querySelectorAll('.markdown-prose')
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 22ee5578..7bbbac6b 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -2652,7 +2652,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.contextReferencesOnly': 'Added {count} references',
'chat.addSelectionToChat': 'Add to chat',
'chat.imageLoadFailed': 'Unable to load image',
- 'chat.imageLoadFailedHint': 'The file may be missing or access may be denied.',
+ 'chat.imageLoadFailedHint': 'The file may be damaged, or the local server did not respond.',
'chat.retryImage': 'Retry image: {name}',
'chat.imageRetrying': 'Retrying…',
'chat.branchFromHere': 'Fork a new conversation',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 472e433f..e72a8d1b 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -2653,7 +2653,7 @@ export const jp: Record = {
'chat.contextReferencesOnly': '参照を {count} 件追加しました',
'chat.addSelectionToChat': 'チャットに追加',
'chat.imageLoadFailed': '画像を読み込めません',
- 'chat.imageLoadFailedHint': 'ファイルが存在しないか、アクセスが許可されていない可能性があります。',
+ 'chat.imageLoadFailedHint': 'ファイルが破損しているか、ローカルサーバーが応答しませんでした。',
'chat.retryImage': '画像を再読み込み: {name}',
'chat.imageRetrying': '再読み込み中…',
'chat.branchFromHere': '新しい会話を分岐',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 61324129..d0f5da58 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -2655,7 +2655,7 @@ export const kr: Record = {
'chat.contextReferencesOnly': '참조 {count}개를 추가했습니다',
'chat.addSelectionToChat': '채팅에 추가',
'chat.imageLoadFailed': '이미지를 불러올 수 없습니다',
- 'chat.imageLoadFailedHint': '파일이 없거나 접근 권한이 없을 수 있습니다.',
+ 'chat.imageLoadFailedHint': '파일이 손상되었거나 로컬 서버가 응답하지 않았습니다.',
'chat.retryImage': '이미지 다시 시도: {name}',
'chat.imageRetrying': '다시 시도 중…',
'chat.branchFromHere': '새 대화 분기',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index aa839801..a866ad4d 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -2652,7 +2652,7 @@ export const zh: Record = {
'chat.contextReferencesOnly': '已新增 {count} 個引用',
'chat.addSelectionToChat': '新增到對話',
'chat.imageLoadFailed': '無法載入圖片',
- 'chat.imageLoadFailedHint': '檔案可能不存在,或沒有存取權限。',
+ 'chat.imageLoadFailedHint': '檔案可能已損壞,或本機服務暫時沒有回應。',
'chat.retryImage': '重試圖片:{name}',
'chat.imageRetrying': '正在重試…',
'chat.branchFromHere': 'Fork 一個新對話',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index f8a883d2..f912eedc 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -2651,7 +2651,7 @@ export const zh: Record = {
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加到对话',
'chat.imageLoadFailed': '无法加载图片',
- 'chat.imageLoadFailedHint': '文件可能不存在,或没有访问权限。',
+ 'chat.imageLoadFailedHint': '文件可能已损坏,或本地服务暂时没有响应。',
'chat.retryImage': '重试图片:{name}',
'chat.imageRetrying': '正在重试…',
'chat.branchFromHere': 'Fork 一个新对话',
diff --git a/desktop/src/lib/useAuthedImageFallback.ts b/desktop/src/lib/useAuthedImageFallback.ts
index 2399844e..8178500d 100644
--- a/desktop/src/lib/useAuthedImageFallback.ts
+++ b/desktop/src/lib/useAuthedImageFallback.ts
@@ -1,16 +1,38 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { ApiError } from '../api/client'
import { fetchServerImageBlobUrl } from './authedImage'
+/**
+ * Why an image did not load.
+ *
+ * `unavailable`: the local server answered that it will not serve the path — the
+ * file is missing, outside what this client may read, or not an image it serves.
+ * A path pulled from a reply often names nothing at all (a pattern, a web route,
+ * a file cleaned out of /tmp since), so this is no fault and no retry fixes it.
+ *
+ * `failed`: something that should have worked did not — a server fault, a dropped
+ * connection, a refused credential, or bytes that do not decode as an image.
+ */
+export type ImageFailure = 'unavailable' | 'failed'
+
+// What the file routes answer for a path they will not serve: 404 missing, 403
+// outside the allowed roots, 400 not an image or not a file, 413 too large.
+const UNAVAILABLE_STATUSES: ReadonlySet = new Set([400, 403, 404, 413])
+
+function classifyFailure(error: unknown): ImageFailure {
+ return error instanceof ApiError && UNAVAILABLE_STATUSES.has(error.status) ? 'unavailable' : 'failed'
+}
+
/**
* Let an `
` that a plain request could not load try once more with the app's
* credential (see {@link fetchServerImageBlobUrl}).
*
* Spread `src` and `onError` onto the image. `onFailure` runs only when the
- * authenticated attempt has also failed — a missing or denied file, or a body that
- * is not an image — so callers keep their own failure notice for real failures and
- * never flash it for a request that was merely missing a header.
+ * authenticated attempt has also failed, and says why (see {@link ImageFailure}),
+ * so callers keep their own failure notice for real failures and never flash it
+ * for a request that was merely missing a header.
*/
-export function useAuthedImageFallback(src: string | undefined, onFailure?: () => void, retryWithCredential = false) {
+export function useAuthedImageFallback(src: string | undefined, onFailure?: (failure: ImageFailure) => void, retryWithCredential = false) {
const attempt = useRef({ src, state: 'idle' as 'idle' | 'fetching' | 'resolved' | 'failed' })
if (attempt.current.src !== src) attempt.current = { src, state: 'idle' }
const current = attempt.current
@@ -34,7 +56,9 @@ export function useAuthedImageFallback(src: string | undefined, onFailure?: () =
if (attempt.current !== current || current.state === 'fetching' || current.state === 'failed') return
if (!src || current.state === 'resolved') {
current.state = 'failed'
- onFailureRef.current?.()
+ // No source names nothing to show. A copy the server did send that still
+ // errors is a file that is there and does not decode.
+ onFailureRef.current?.(src ? 'failed' : 'unavailable')
return
}
current.state = 'fetching'
@@ -47,10 +71,10 @@ export function useAuthedImageFallback(src: string | undefined, onFailure?: () =
current.state = 'resolved'
objectUrls.current.push(url)
setResolved({ attempt: current, url })
- }).catch(() => {
+ }).catch((error: unknown) => {
if (alive.current && attempt.current === current) {
current.state = 'failed'
- onFailureRef.current?.()
+ onFailureRef.current?.(classifyFailure(error))
}
})
}, [src, current, retryWithCredential])
diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts
index f03a449e..b8a9a462 100644
--- a/src/server/__tests__/filesystem.test.ts
+++ b/src/server/__tests__/filesystem.test.ts
@@ -376,6 +376,22 @@ describe('filesystem API', () => {
expect(Buffer.from(await res.arrayBuffer()).equals(PNG)).toBe(true)
})
+ it('answers 404 for a picture missing from an allowed root', async () => {
+ // The chat reads 400/403/404 as "not there to show" and leaves the picture out
+ // quietly; any other answer is an error it offers to retry. A file that is
+ // simply missing — cleaned out of /tmp since the reply named it — stays a 404.
+ const dir = fs.realpathSync(await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-filesystem-test-')))
+ cleanupDirs.add(dir)
+ registerFilesystemAccessRoot(dir)
+
+ const res = await handleFilesystemRoute(
+ '/api/filesystem/file',
+ makeUrl('/api/filesystem/file', { path: path.join(dir, 'cleaned', 'chart.png') }),
+ )
+
+ expect(res.status).toBe(404)
+ })
+
it('still keeps a home-relative path inside the allowed roots', async () => {
// A picture that really exists, outside $HOME, the temp directories and every
// registered root, reached by climbing out of the home directory. A missing