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 `
`.