mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
fix(markdown): reserve pending images and harden web rendering
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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 = "\n\n";
|
||||
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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user