Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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.
Expand Down
46 changes: 38 additions & 8 deletions src/components/ImageUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import {
mediaLoadErrorLabelMap,
resolveMediaSrcFailure,
shouldApplyChatImageLoadError,
type MediaLoadErrorKind,
} from "@/lib/mediaLoadPro";
import {
Expand All @@ -41,6 +42,8 @@ import {
} from "@/lib/imageAspectCache";
import {
canUseImageThumb,
nextChatCardDisplaySrc,
peekChatImageThumb,
resolveChatImageThumb,
} from "@/lib/imageThumbClient";
import { isFusedQueryKeyPath } from "@/lib/pathNormalize";
Expand Down Expand Up @@ -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 <img>
// 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);
}
Expand Down Expand Up @@ -194,10 +208,12 @@ export function ImageUi({
const layout = resolveLayout(layoutProp, className);
const viewer = useImageViewerOptional();
const imgRef = useRef<HTMLImageElement | null>(null);
const paintedSrcRef = useRef<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [resolvedSrc, setResolvedSrc] = useState<string | null>(() =>
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). */
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -551,6 +570,7 @@ export function ImageUi({
</span>
) : resolvedSrc ? (
<img
key={resolvedSrc}
ref={imgRef}
className="md-body__img-frame__el"
src={resolvedSrc}
Expand All @@ -570,11 +590,21 @@ export function ImageUi({
const el = e.currentTarget;
applyNaturalSize(el.naturalWidth, el.naturalHeight);
}}
onError={() => {
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(),
Expand Down
48 changes: 48 additions & 0 deletions src/lib/imageThumbClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
clearChatImageThumbClientCache,
canUseImageThumb,
getThumbCacheEndpoint,
nextChatCardDisplaySrc,
peekChatImageThumb,
resolveChatImageThumb,
} from "./imageThumbClient";
import {
Expand Down Expand Up @@ -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" });
Expand Down
25 changes: 25 additions & 0 deletions src/lib/imageThumbClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>` 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.
Expand Down
25 changes: 25 additions & 0 deletions src/lib/mediaLoadPro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
mediaUrlPathParam,
resolveMediaLoadError,
resolveMediaSrcFailure,
shouldApplyChatImageLoadError,
} from "./mediaLoadPro";

describe("classifyMediaLoadError", () => {
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions src/lib/mediaLoadPro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,24 @@ export function mediaUrlPathParam(
}
}

/**
* Whether `<img onError>` 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 `<img onError>`.
Expand Down
Loading