diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db17f5c..27ccf43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 CI Release body = this section only (via `scripts/changelog-for-release.py`; no repeated download/install boilerplate). See `docs/llm-wiki/release.md`. +## [Unreleased] + +### Fixed +- **Chat image cards no longer die after the turn ends**: Leftover remote https thumbs (web-fetch charts, etc.) first-paint from the in-memory thumb cache on journal remount. Swapping `src` mid-load used to abort the original `` and lock `broken_blob` (“preview failed”). Abort / stale-src errors are ignored; a working https original is not wiped when thumb resolve returns empty. + ## [0.2.17] - 2026-08-14 > **Highlight:** Stability release — shared-process session isolation (no cross-chat history poisoning, per-session turn completion), thinking timer stays live across tool loops, chat bottom jitter gone, side-terminal Powerline/truecolor, MCP OAuth follows the selected server, and macOS no longer crashes on voice. diff --git a/src/components/ImageUi.tsx b/src/components/ImageUi.tsx index 7e748ca8..f6086a6d 100644 --- a/src/components/ImageUi.tsx +++ b/src/components/ImageUi.tsx @@ -33,6 +33,7 @@ import { import { mediaLoadErrorLabelMap, resolveMediaSrcFailure, + shouldApplyChatImageLoadError, type MediaLoadErrorKind, } from "@/lib/mediaLoadPro"; import { @@ -41,6 +42,8 @@ import { } from "@/lib/imageAspectCache"; import { canUseImageThumb, + nextChatCardDisplaySrc, + peekChatImageThumb, resolveChatImageThumb, } from "@/lib/imageThumbClient"; import { isFusedQueryKeyPath } from "@/lib/pathNormalize"; @@ -117,7 +120,18 @@ function isLocalFsPath(path: string | undefined): path is string { return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path); } -function initialResolvedSrc(src: string): string | null { +function initialResolvedSrc( + src: string, + path?: string, + layout?: ImageUiLayout, +): string | null { + // Remount after journal rehydrate must first-paint the cached thumb. + // Painting https then swapping to loopback aborts the in-flight + // load; WKWebView onError then locked the card as broken_blob. + if (layout !== "pane") { + const peek = peekChatImageThumb(src, path)?.displaySrc; + if (peek) return peek; + } if (isViewableSrc(src)) return src; return resolveImageSrcSync(src); } @@ -194,10 +208,12 @@ export function ImageUi({ const layout = resolveLayout(layoutProp, className); const viewer = useImageViewerOptional(); const imgRef = useRef(null); + const paintedSrcRef = useRef(null); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [resolvedSrc, setResolvedSrc] = useState(() => - initialResolvedSrc(src), + initialResolvedSrc(src, path, layout), ); + paintedSrcRef.current = resolvedSrc; /** Once load fails, keep a stable broken state — never re-fetch on re-render. */ const [loadFailed, setLoadFailed] = useState(false); /** Classified reason when resolve or decode fails (MEDIA-LOAD-PRO). */ @@ -248,7 +264,8 @@ export function ImageUi({ useEffect(() => { let cancelled = false; - const next = initialResolvedSrc(src); + const next = initialResolvedSrc(src, path, layout); + paintedSrcRef.current = next; setResolvedSrc(next); setLoadFailed(false); setFailKind(null); @@ -272,11 +289,13 @@ export function ImageUi({ void resolveChatImageThumb(src, path) .then((r) => { if (cancelled) return; - if (r?.displaySrc) { - setResolvedSrc(r.displaySrc); + const display = nextChatCardDisplaySrc(next, r); + if (display) { + paintedSrcRef.current = display; + setResolvedSrc((prev) => (prev === display ? prev : display)); setLoadFailed(false); setFailKind(null); - if (r.width > 0 && r.height > 0) { + if (r && r.width > 0 && r.height > 0) { applyNaturalSize(r.width, r.height); } } else { @@ -551,6 +570,7 @@ export function ImageUi({ ) : resolvedSrc ? ( { + onError={(e) => { + const failed = + e.currentTarget.currentSrc || e.currentTarget.src; + if ( + !shouldApplyChatImageLoadError({ + failedSrc: failed, + paintedSrc: paintedSrcRef.current, + }) + ) { + return; + } setLoadFailed(true); const r = resolveMediaSrcFailure({ pathOrUrl: path || src, - resolvedSrc, + resolvedSrc: paintedSrcRef.current, loadFailed: true, isTauri: isTauri(), mediaEndpointReady: isMediaEndpointReady(), diff --git a/src/lib/imageThumbClient.test.ts b/src/lib/imageThumbClient.test.ts index bea946d8..40092be8 100644 --- a/src/lib/imageThumbClient.test.ts +++ b/src/lib/imageThumbClient.test.ts @@ -4,6 +4,8 @@ import { clearChatImageThumbClientCache, canUseImageThumb, getThumbCacheEndpoint, + nextChatCardDisplaySrc, + peekChatImageThumb, resolveChatImageThumb, } from "./imageThumbClient"; import { @@ -49,6 +51,52 @@ describe("resolveChatImageThumb — fused paths never hit the thumb host", () => }); }); +describe("peekChatImageThumb / nextChatCardDisplaySrc", () => { + it("seeds remount from the in-memory thumb cache (no https→loopback swap)", async () => { + setMediaEndpoint({ baseUrl: "http://127.0.0.1:9", token: "tok" }); + vi.spyOn(api, "isDesktopHost").mockReturnValue(true); + vi.spyOn(api, "mediaImageThumb").mockResolvedValue({ + thumbPath: "/tmp/cache/image-thumbs/x.jpg", + fromCache: true, + width: 480, + height: 283, + isOriginal: false, + }); + const remote = + "https://gbres.example/Files/iimage/20260814/chart.png"; + expect(peekChatImageThumb(remote)).toBeNull(); + + const resolved = await resolveChatImageThumb(remote); + expect(resolved?.displaySrc).toContain("127.0.0.1:9"); + + const peek = peekChatImageThumb(remote); + expect(peek?.displaySrc).toBe(resolved?.displaySrc); + expect(peekChatImageThumb(remote, remote)?.displaySrc).toBe( + resolved?.displaySrc, + ); + }); + + it("does not wipe a working https src when thumb resolve returns null", () => { + const live = "https://cdn.example/chart.png"; + expect(nextChatCardDisplaySrc(live, null)).toBe(live); + expect(nextChatCardDisplaySrc(live, { displaySrc: "" } as never)).toBe( + live, + ); + }); + + it("prefers a successful thumb displaySrc over the live src", () => { + expect( + nextChatCardDisplaySrc("https://cdn.example/chart.png", { + displaySrc: "http://127.0.0.1:9/v1/media?t=tok&p=%2Ftmp%2Ft.jpg", + fullKey: "https://cdn.example/chart.png", + width: 480, + height: 283, + fromCache: true, + }), + ).toBe("http://127.0.0.1:9/v1/media?t=tok&p=%2Ftmp%2Ft.jpg"); + }); +}); + describe("resolveChatImageThumb — endpoint change drops stale loopback URLs", () => { it("clears cached displaySrc when the media endpoint changes", async () => { setMediaEndpoint({ baseUrl: "http://127.0.0.1:1", token: "old" }); diff --git a/src/lib/imageThumbClient.ts b/src/lib/imageThumbClient.ts index 1e04da14..df332f50 100644 --- a/src/lib/imageThumbClient.ts +++ b/src/lib/imageThumbClient.ts @@ -65,6 +65,31 @@ function cacheKey(src: string, path?: string): string { return (src || "").trim(); } +/** Sync lookup of the session thumb cache — remounts must first-paint this. */ +export function peekChatImageThumb( + src: string, + path?: string, +): ThumbResolve | null { + const key = cacheKey(src, path); + if (!key) return null; + return displayCache.get(key) ?? null; +} + +/** + * Next `` after a thumb resolve. Never wipe a working live URL + * when materialization returns null / empty (journal remount would flash + * a broken card over a still-valid https original). + */ +export function nextChatCardDisplaySrc( + current: string | null | undefined, + thumb: ThumbResolve | null | undefined, +): string | null { + const next = (thumb?.displaySrc || "").trim(); + if (next) return next; + const keep = (current || "").trim(); + return keep || null; +} + /** * Resolve a chat-card display URL, preferring a Host-cached thumb. * Falls back to normal media resolve when thumb fails. diff --git a/src/lib/mediaLoadPro.test.ts b/src/lib/mediaLoadPro.test.ts index 027ebf58..29447bfb 100644 --- a/src/lib/mediaLoadPro.test.ts +++ b/src/lib/mediaLoadPro.test.ts @@ -11,6 +11,7 @@ import { mediaUrlPathParam, resolveMediaLoadError, resolveMediaSrcFailure, + shouldApplyChatImageLoadError, } from "./mediaLoadPro"; describe("classifyMediaLoadError", () => { @@ -131,6 +132,30 @@ describe("classifyMediaSrcFailure", () => { ).toBe("broken_blob"); }); + it("shouldApplyChatImageLoadError ignores abort leftover from a src swap", () => { + const remote = "https://gbres.example/chart.png"; + const thumb = "http://127.0.0.1:9/v1/media?t=tok&p=%2Ftmp%2Ft.jpg"; + expect( + shouldApplyChatImageLoadError({ + failedSrc: remote, + paintedSrc: thumb, + }), + ).toBe(false); + expect( + shouldApplyChatImageLoadError({ + failedSrc: remote, + paintedSrc: remote, + mediaElementError: "aborted", + }), + ).toBe(false); + expect( + shouldApplyChatImageLoadError({ + failedSrc: thumb, + paintedSrc: thumb, + }), + ).toBe(true); + }); + it("broken blob for remote http image decode failures (not allowlist)", () => { expect( classifyMediaSrcFailure({ diff --git a/src/lib/mediaLoadPro.ts b/src/lib/mediaLoadPro.ts index 049cc7ee..dba8eb13 100644 --- a/src/lib/mediaLoadPro.ts +++ b/src/lib/mediaLoadPro.ts @@ -316,6 +316,24 @@ export function mediaUrlPathParam( } } +/** + * Whether `` should lock the chat card as broken. + * Ignore leftover abort from a src swap (https → cached thumb on remount) + * and explicit media-element abort codes. + */ +export function shouldApplyChatImageLoadError(input: { + failedSrc?: string | null; + paintedSrc?: string | null; + mediaElementError?: string | null; +}): boolean { + const err = (input.mediaElementError || "").trim().toLowerCase(); + if (err === "aborted" || err === "media_err_aborted") return false; + const failed = (input.failedSrc || "").trim(); + const painted = (input.paintedSrc || "").trim(); + if (failed && painted && failed !== painted) return false; + return true; +} + /** * Classify resolve / paint failures when we have path context but no thrown value. * Used by chat image cards when `resolveImageSrc` returns null or ``.