mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(markdown): preserve file links and image revisions
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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 `&`",
|
||||
);
|
||||
});
|
||||
|
||||
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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user