fix(markdown): preserve file links and image revisions

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:19 +02:00
parent 35302f7251
commit 554530a667
17 changed files with 186 additions and 56 deletions
@@ -252,9 +252,12 @@ test("reloading a timeline reserves image height without a load-time scroll jump
scrollTop: number;
}>;
const loadedIndex = geometryEvents.findIndex((event) => event.complete && event.height > 0);
expect(loadedIndex, JSON.stringify(events)).toBeGreaterThan(0);
const beforeLoad = geometryEvents[loadedIndex - 1]!;
expect(loadedIndex, JSON.stringify(events)).toBeGreaterThanOrEqual(0);
const beforeLoad = geometryEvents[Math.max(0, loadedIndex - 1)]!;
const loaded = geometryEvents[loadedIndex]!;
expect(
new Set(geometryEvents.filter((event) => event.height > 0).map((event) => event.height)).size,
).toBe(1);
expect(loaded.height).toBe(beforeLoad.height);
expect(loaded.scrollTop).toBe(beforeLoad.scrollTop);
});
@@ -297,3 +300,26 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
response: "stress-update-0",
});
});
test("a later reply reads new bytes at the same workspace image path", async ({
imageWorkspace: workspace,
page,
}) => {
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
const first = await createSmallAssistantPng(workspace, {
alt: "Earlier screenshot",
fileName: "screenshot.png",
});
await emitSettledAssistantImage(workspace.client, agent, first);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, first);
const second = await createSmallAssistantPng(workspace, {
alt: "Updated screenshot",
fileName: "screenshot.png",
width: 2,
height: 3,
});
await emitSettledAssistantImage(workspace.client, agent, second);
await expectAssistantImageRendered(page, second);
await expectAssistantImageRendered(page, first);
});
@@ -33,9 +33,9 @@ test.describe("Plan card markdown", () => {
test("chat and file preview share the library renderer and resolve file links", async ({
page,
}, testInfo) => {
const { writeFile } = await import("node:fs/promises");
const { writeFile, mkdir } = await import("node:fs/promises");
const source =
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key | Value |\n| --- | --- |\n| Answer | 42 |\n\nOpen `README.md`.";
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`.";
const session = await seedMockAgentWorkspace({
repoPrefix: "shared-markdown-",
title: "Shared Markdown",
@@ -44,6 +44,11 @@ test("chat and file preview share the library renderer and resolve file links",
});
try {
await writeFile(`${session.cwd}/README.md`, source);
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
await writeFile(
`${session.cwd}/packages/app/src/说明.md`,
"# Nested file\n\nUnicode suffix lookup succeeded.",
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
@@ -52,10 +57,16 @@ test("chat and file preview share the library renderer and resolve file links",
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
await expect(message.locator("blockquote")).toContainText("A quote");
await expect(message.getByRole("table")).toContainText("42");
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 message.getByRole("link", { name: "README.md" }).click();
const preview = page.getByTestId("markdown-preview-frame");
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
await expect(preview.getByRole("table")).toContainText("42");
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
await message.getByRole("link", { name: "src/说明.md:2-3" }).click();
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await testInfo.attach("shared-renderer", {
body: await page.screenshot(),
contentType: "image/png",
@@ -26,16 +26,13 @@ export interface AssistantImageFixture {
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
input: { alt: string; fileName: string; width?: number; height?: number },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
const width = input.width ?? 1;
const height = input.height ?? 1;
const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
}
export async function createNearTenMegabyteAssistantPng(
+2
View File
@@ -105,7 +105,9 @@
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm-table": "^2.0.0",
"mermaid": "^11.16.0",
"micromark-extension-gfm-table": "^2.1.2",
"micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
@@ -2,7 +2,7 @@ export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
} from "./paths";
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
@@ -5,7 +5,7 @@ import {
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./parse";
} from "./index";
describe("parseInlinePathToken", () => {
it("returns null for plain paths without a line number", () => {
@@ -10,7 +10,7 @@ import {
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
@@ -3,7 +3,7 @@ import {
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
} from "./paths";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
@@ -6,7 +6,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
+10 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useEffect } from "react";
import { useMemo, useEffect, useId } from "react";
import { useFetchQueries } from "@/data/query";
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
import {
@@ -33,7 +33,11 @@ export interface MarkdownImageContext {
}
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
const prepared = useMemo(() => prepareMarkdownSource(text), [text]);
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot),
[text, context.workspaceRoot],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
const resolved = context.filePath
@@ -54,7 +58,7 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
: null,
serverId: context.serverId,
occurrenceKey: context.filePath ?? context.workspaceRoot ?? "markdown",
occurrenceKey,
unavailableMessage: "Image unavailable",
});
});
@@ -90,6 +94,9 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
});
return {
markdown: prepared.render(resolved),
linkRecognition: prepared.linkRecognition,
linkPillContent: prepared.linkPillContent,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
}
@@ -36,3 +36,30 @@ it("preserves named and numeric entities as literal rendered text", () => {
"AT\\&T 😀 \\# heading and `&amp;`",
);
});
it("preserves empty table cells while escaping spoilers in cell text", () => {
const source = "| A || B |\n| --- | --- | --- |\n| x || z |";
expect(prepareMarkdownSource(source).render()).toBe(
"| A | | B |\n| --- | --- | --- |\n| x | | z |",
);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
"docs/说明.md",
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/workspace");
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path)).toEqual({ href: path, text: path, sourceType: "inline-code" });
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.", "/workspace");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
"说明.md",
]);
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
});
@@ -1,6 +1,6 @@
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { memo, useMemo, useState } from "react";
import { memo, useState } from "react";
import {
EnrichedMarkdownText,
type MarkdownStyle,
@@ -20,12 +20,6 @@ import { useMarkdownSource, type MarkdownImageContext } from "./images";
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
const FILE_PATTERN =
/(?:https?:\/\/[^\s<>]+|(?:\.{0,2}\/|~\/)?[\w@.-]+(?:\/[\w@.-]+)+(?::\d+(?::\d+)?)?|[\w@-]+\.[a-zA-Z][\w-]*(?::\d+(?::\d+)?)?)/g;
const LINK_RECOGNITION = {
text: FILE_PATTERN,
inlineCode: new RegExp(`^(?:${FILE_PATTERN.source})$`),
};
function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
@@ -140,15 +134,16 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
(environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) }
: undefined);
const { markdown, imageSources } = useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const { markdown, imageSources, linkRecognition, linkPillContent, linkSource } =
useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url }: { url: string }) => {
const source = { href: url, text: url };
const source = linkSource(url);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
});
@@ -156,15 +151,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
setImage({ type: "uri", uri: url }),
);
const closeImage = useStableEvent(() => setImage(null));
const pills = useMemo(
() =>
Object.fromEntries(
[...markdown.matchAll(FILE_PATTERN)]
.filter(([url]) => !/^https?:/i.test(url))
.map(([url]) => [url, { label: url }]),
),
[markdown],
);
const content = (
<>
<ThemedMarkdown
@@ -174,8 +160,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={LINK_RECOGNITION}
linkPillContent={pills}
linkRecognition={linkRecognition}
linkPillContent={linkPillContent}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
+61 -6
View File
@@ -1,6 +1,46 @@
import { gfmTable } from "micromark-extension-gfm-table";
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import { classifyAssistantFileLink } from "@/assistant-file-links/paths";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Nodes } from "mdast";
import type { Nodes, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
const FILE_PATTERN =
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@-]+\.[a-zA-Z0-9\u0080-\uFFFF_-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
function linkMetadata(source: string, getTree: () => Root, workspaceRoot?: string) {
return {
linkPillContent: Object.fromEntries(
[...source.matchAll(FILE_PATTERN)]
.filter(([url]) => !/^https?:/i.test(url))
.map(([url]) => [url, { label: url }]),
),
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
),
},
linkSource(url: string): AssistantFileLinkSource {
function containsInlinePath(node: Nodes): boolean {
return (
(node.type === "inlineCode" && node.value === url) ||
("children" in node && node.children.some(containsInlinePath))
);
}
// The library emits only the URL. Recover raw inline-code semantics at tap time,
// without parsing every reveal frame merely to configure link recognition.
return source.includes("`") &&
classifyAssistantFileLink(url, { workspaceRoot }) &&
containsInlinePath(getTree())
? { href: url, text: url, sourceType: "inline-code" }
: { href: url, text: url };
},
};
}
export interface MarkdownImage {
source: string;
@@ -11,23 +51,37 @@ export interface MarkdownImage {
}
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
export function prepareMarkdownSource(source: string) {
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
const images: MarkdownImage[] = [];
let tree: Root | undefined;
const getTree = () =>
(tree ??= fromMarkdown(source, {
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const edits: { start: number; end: number; text: string }[] = [];
if (!source.includes("![") && !source.includes("||") && !source.includes("&")) {
return { images, render: () => source };
return { images, ...linkMetadata(source, getTree, workspaceRoot), render: () => source };
}
const tree = fromMarkdown(source);
const definitions = new Map<string, { url: string; title?: string | null }>();
function definitionsIn(node: Nodes) {
if (node.type === "definition") definitions.set(node.identifier, node);
if ("children" in node) node.children.forEach(definitionsIn);
}
definitionsIn(tree);
definitionsIn(getTree());
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
if (node.type === "tableRow") {
// MD4C's always-enabled spoiler extension otherwise consumes adjacent cell
// delimiters. Spaces keep empty GFM cells empty without producing spoilers.
const row = source.slice(start, end);
for (const match of row.matchAll(/(?<!\\)\|(?=\|)/g)) {
edits.push({ start: start + match.index + 1, end: start + match.index + 1, text: " " });
}
}
if (node.type === "image" || node.type === "imageReference") {
const target = node.type === "image" ? node : definitions.get(node.identifier);
if (target)
@@ -52,9 +106,10 @@ export function prepareMarkdownSource(source: string) {
}
if ("children" in node) node.children.forEach(visit);
}
visit(tree);
visit(getTree());
return {
images,
...linkMetadata(source, getTree, workspaceRoot),
render(urls: ReadonlyMap<string, string> = new Map()) {
const replacements = [...edits];
for (const image of images) {