fix(markdown): reserve pending images and harden web rendering

This commit is contained in:
Mohamed Boudra
2026-10-15 17:17:49 +02:00
parent 30b7b269e9
commit 9e240d5d61
9 changed files with 162 additions and 31 deletions
@@ -1,3 +1,5 @@
import type { Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
@@ -323,3 +325,45 @@ test("a later reply reads new bytes at the same workspace image path", async ({
await expectAssistantImageRendered(page, second);
await expectAssistantImageRendered(page, first);
});
test("pending assistant images reserve their decoded height", async ({
imageWorkspace: workspace,
page,
}) => {
const image = await createSmallAssistantPng(workspace, {
alt: "Delayed remote image",
fileName: "delayed.png",
});
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
const url = "https://markdown-image.test/delayed.png";
const release = await holdRemoteImage(
page,
url,
await readFile(path.join(workspace.repoPath, image.relativePath)),
);
try {
await emitSettledAssistantImage(workspace.client, agent, image, url);
await openAssistantImageTimeline(page, agent);
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/gif/);
const pendingBox = await rendered.boundingBox();
expect(pendingBox?.height).toBe(320);
release();
await expect(rendered).toHaveAttribute("src", url);
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
} finally {
release();
}
});
async function holdRemoteImage(page: Page, url: string, bytes: Buffer): Promise<() => void> {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(url, async (route) => {
await ready;
await route.fulfill({ contentType: "image/png", body: bytes });
});
return release;
}
@@ -56,6 +56,8 @@ test("chat and file preview share the library renderer and resolve file links",
"# Nested file\n\nUnicode suffix lookup succeeded.",
);
await session.client.waitForFinish(session.agentId, 30_000);
// A cold render must work even when a separate parser chunk cannot be fetched.
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message).toHaveCount(1);
@@ -66,6 +68,7 @@ test("chat and file preview share the library renderer and resolve file links",
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
await expect(message.getByRole("cell").nth(1)).toHaveText("");
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
await expect(message.locator("p div")).toHaveCount(0);
await message.getByRole("link", { name: "README.md" }).click();
const preview = page.getByTestId("markdown-preview-frame");
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
+17 -3
View File
@@ -112,12 +112,26 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
})),
);
const unavailableImages = new Set(
sources.filter(
(_source, index) => displayUrls[index] !== undefined && decoded[index]?.data !== true,
sources.filter((_source, index) =>
isWeb
? (!acquisitions[index] && displayUrls[index] === undefined) ||
results[index]?.isError ||
decoded[index]?.isError
: displayUrls[index] !== undefined && decoded[index]?.data !== true,
),
);
return {
markdown: prepared.render(resolved, unavailableImages),
markdown: prepared.render(
resolved,
unavailableImages,
new Set(
isWeb
? sources.filter(
(source, index) => !unavailableImages.has(source) && decoded[index]?.data !== true,
)
: [],
),
),
linkRecognition: prepared.linkRecognition,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
@@ -391,3 +391,18 @@ it("keeps the document's first reference definition across every native Mermaid
expect(segments[2].source.endsWith(after)).toBe(true);
expect(segments[1]).toEqual({ kind: "mermaid", id: "1", source: "graph TD\nA-->B" });
});
it("reserves pending image geometry until decode succeeds and collapses only confirmed failures", () => {
const source = "![Local](shot.png)\n\n![Remote](https://example.com/image.png)";
const prepared = prepareMarkdownSource(source);
const pending = prepared.render(
new Map(),
new Set(),
new Set(prepared.images.map((image) => image.source)),
);
expect(pending.match(/!\[/g)).toHaveLength(2);
expect(pending).toContain("data:image/gif;base64,");
expect(prepared.render(new Map(), new Set(prepared.images.map((image) => image.source)))).toBe(
"Local\n\nRemote",
);
});
@@ -12,6 +12,7 @@ import { isWeb } from "@/constants/platform";
import { MarkdownRendererFrame } from "./frame";
import { WEB_RENDERERS } from "./renderers";
import { createMarkdownStyles } from "./styles";
import { PENDING_MARKDOWN_IMAGE } from "./source";
import { useMarkdownSource, type MarkdownImageContext } from "./images";
import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fence/mermaid";
@@ -57,9 +58,9 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
});
const pressImage = useStableEvent(({ url }: { url: string }) =>
setImage({ type: "uri", uri: url }),
);
const pressImage = useStableEvent(({ url }: { url: string }) => {
if (url !== PENDING_MARKDOWN_IMAGE) setImage({ type: "uri", uri: url });
});
const closeImage = useStableEvent(() => setImage(null));
const segments = useMemo(
() =>
@@ -1,4 +1,5 @@
import { Text, View, type ViewStyle } from "react-native";
import { Text } from "react-native";
import { forwardRef, type ComponentProps } from "react";
import type {
CodeBlockRendererProps,
LinkRendererProps,
@@ -32,7 +33,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<View style={INLINE_LINK}>{anchor}</View>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
@@ -44,7 +45,23 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
</Tooltip>
);
}
const INLINE_LINK: ViewStyle = { display: "inline-flex" as ViewStyle["display"] };
// TooltipTrigger also supplies native Pressable handlers; forward only DOM events.
// A View renders a div, which is invalid inside the library's prose paragraphs.
const InlineLinkTrigger = forwardRef<HTMLSpanElement, ComponentProps<"span">>(
function InlineLinkTrigger({ children, onMouseEnter, onMouseLeave, onFocus, onBlur }, ref) {
return (
<span
ref={ref}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onBlur}
>
{children}
</span>
);
},
);
const styles = StyleSheet.create((theme) => ({
tooltipPath: {
color: theme.colors.foreground,
@@ -32,6 +32,10 @@ export function markdownLinkSource(
return { href: url, text: url };
}
// A decoded transparent pixel reserves the library's normal block/inline image box.
export const PENDING_MARKDOWN_IMAGE =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export interface MarkdownImage {
source: string;
alt: string;
@@ -142,12 +146,15 @@ export function prepareMarkdownSource(
render(
urls: ReadonlyMap<string, string> = new Map(),
unavailableImages: ReadonlySet<string> = new Set(),
pendingImages: ReadonlySet<string> = new Set(),
) {
const replacements = [...edits];
for (const image of images) {
const unavailable = unavailableImages.has(image.source);
if (!unavailable && /^(https?:|data:|blob:)/i.test(image.source)) continue;
const url = unavailable ? undefined : urls.get(image.source);
const pending = pendingImages.has(image.source);
if (!unavailable && !pending && /^(https?:|data:|blob:)/i.test(image.source)) continue;
let url = unavailable ? undefined : urls.get(image.source);
if (pending && !unavailable) url = PENDING_MARKDOWN_IMAGE;
replacements.push({
start: image.start,
end: image.end,
+24 -9
View File
@@ -41,10 +41,12 @@ interface Rect {
height: number;
}
type TooltipTriggerElement = View | HTMLElement;
interface TooltipContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
triggerRef: React.RefObject<TooltipTriggerElement | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
@@ -120,12 +122,19 @@ function useControllableOpenState({
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
function measureElement(element: TooltipTriggerElement): Promise<Rect> {
if (isWeb && element instanceof HTMLElement) {
const { x, y, width, height } = element.getBoundingClientRect();
return Promise.resolve({ x, y, width, height });
}
if ("measureInWindow" in element) {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
});
}
throw new Error("A DOM tooltip trigger requires the web platform");
}
function resolveActualSide(args: {
@@ -240,7 +249,7 @@ export function Tooltip({
enabledOnDesktop?: boolean;
enabledOnMobile?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const triggerRef = useRef<TooltipTriggerElement>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
@@ -282,6 +291,12 @@ export function TooltipTrigger({
triggerRefProp?: string;
}): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const setTriggerRef = useCallback(
(node: View | null) => {
ctx.triggerRef.current = node;
},
[ctx.triggerRef],
);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearOpenTimer = useCallback(() => {
@@ -411,7 +426,7 @@ export function TooltipTrigger({
};
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
mergedProps[triggerRefProp] = (node: View) => {
mergedProps[triggerRefProp] = (node: TooltipTriggerElement) => {
if (isCallable(existingRefProp)) {
existingRefProp(node);
} else if (existingRefProp && typeof existingRefProp === "object") {
@@ -424,7 +439,7 @@ export function TooltipTrigger({
}
return (
<Pressable {...triggerProps} ref={ctx.triggerRef} collapsable={false}>
<Pressable {...triggerProps} ref={setTriggerRef} collapsable={false}>
{children}
</Pressable>
);
File diff suppressed because one or more lines are too long