Skip to content
Closed
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
7 changes: 5 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import { repairMarkdownImageDestinations } from "@t3tools/client-runtime/markdown-links";
import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";
import {
renderCodexFileCitationsAsMarkdown,
Expand Down Expand Up @@ -788,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
}) {
const segments = useMemo(
() => splitCodexArtifactTemplateMarkdown(props.markdown),
() => splitCodexArtifactTemplateMarkdown(repairMarkdownImageDestinations(props.markdown)),
[props.markdown],
);

Expand Down Expand Up @@ -1515,7 +1516,9 @@ function renderFeedEntry(
);
}
const isUser = message.role === "user";
const renderedText = renderAssistantCitationsAsText(message.text);
const renderedText = renderAssistantCitationsAsText(
repairMarkdownImageDestinations(message.text),
);
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
Expand Down
18 changes: 13 additions & 5 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
import {
inlineCodeFilePathCandidate,
markdownImageDestination,
repairMarkdownImageDestinations,
} from "@t3tools/client-runtime/markdown-links";
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -1300,7 +1304,10 @@ function markdownImageCopy(alt: string, src: string, title: string | undefined):
const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
const titleSuffix =
title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
return `![${escapedAlt}](${src}${titleSuffix})`;
// ReactMarkdown hands back the parsed source, so a path with a space
// arrives here unquoted and would paste as text that no other Markdown
// reader renders as an image.
return `![${escapedAlt}](${markdownImageDestination(src)}${titleSuffix})`;
}

/**
Expand Down Expand Up @@ -3288,18 +3295,19 @@ function ChatMarkdown({
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
...props
}: ChatMarkdownProps) {
const repairedText = useMemo(() => repairMarkdownImageDestinations(text), [text]);
const {
componentState,
handleCopy,
markdownRef,
markdownUrlTransform,
localMediaPreview,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
} = useChatMarkdownState({ text: repairedText, ...props });
const incrementalParsing =
props.isStreaming === true &&
extraRemarkPlugins.length === 0 &&
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(repairedText);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
Expand Down Expand Up @@ -3331,7 +3339,7 @@ function ChatMarkdown({
components={CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
{text}
{repairedText}
</ReactMarkdown>
</ChatMarkdownRendererContext>
{localMediaPreview ? (
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/components/ChatMarkdown.workspace-images.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ function renderFilePreview(cwd: string, relativePath: string): string {
}

function copiedMarkdownFrom(html: string): string {
const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll("&quot;", '"');
const copy = /data-markdown-copy="([^"]*)"/
.exec(html)?.[1]
?.replaceAll("&quot;", '"')
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
expect(copy).toBeDefined();
return copy ?? "";
}
Expand Down Expand Up @@ -172,6 +177,41 @@ describe("ChatMarkdown workspace images", () => {
expect(html).not.toContain("Image unavailable");
});

it("loads a workspace path with spaces an agent wrote unquoted", () => {
const html = render(String.raw`![Settings → General](D:\my projects\demo shots\cmp.png)`);

expect(testState.resources).toEqual([
{
_tag: "media-file",
threadId: threadRef.threadId,
path: String.raw`D:\my projects\demo shots\cmp.png`,
},
]);
expect(html).toContain("https://signed.test/workspace-image.svg");
expect(html).not.toContain("Image unavailable");
});

it("keeps a repaired path's separators through the parser's own escapes", () => {
const html = render(String.raw`![cache](D:\my projects\.cache\a b.png)`);

expect(testState.resources).toEqual([
{
_tag: "media-file",
threadId: threadRef.threadId,
path: String.raw`D:\my projects\.cache\a b.png`,
},
]);
expect(html).not.toContain("Image unavailable");
});

it("copies a spaced destination in a form another Markdown reader can parse", () => {
const html = render(String.raw`<img src="D:\my projects\demo shots\cmp.png" alt="shot">`);

expect(copiedMarkdownFrom(html)).toBe(
String.raw`![shot](<D:\\my projects\\demo shots\\cmp.png>)`,
);
});

it("normalizes a drive-absolute src in raw image HTML", () => {
const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`);

Expand Down
182 changes: 182 additions & 0 deletions packages/client-runtime/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
inlineCodeFilePathCandidate,
parseFileUrlHref,
parseMarkdownFileLink,
repairMarkdownImageDestinations,
splitFilePathPosition,
workspaceRelativeFilePath,
} from "./markdownLinks.ts";
Expand Down Expand Up @@ -168,3 +169,184 @@ describe("workspaceRelativeFilePath", () => {
expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
});
});

describe("repairMarkdownImageDestinations", () => {
it("angle-quotes a Windows path with spaces an agent wrote as the destination", () => {
const markdown = String.raw`![Settings → General](D:\my projects\demo shots\cmp.png)`;
expect(repairMarkdownImageDestinations(markdown)).toBe(
String.raw`![Settings → General](<D:\\my projects\\demo shots\\cmp.png>)`,
);
});

it("doubles the backslashes so a path segment starting with punctuation survives", () => {
// `\.` and `\_` are escapes inside a destination: written once, the parser
// hands back `D:\shots.cache\a b.png` and the file is never found.
expect(repairMarkdownImageDestinations(String.raw`![a](D:\shots\.cache\_x\a b.png)`)).toBe(
String.raw`![a](<D:\\shots\\.cache\\_x\\a b.png>)`,
);
});

it("keeps a UNC path's leading pair of separators", () => {
expect(repairMarkdownImageDestinations(String.raw`![a](\\server\share\my shots\a b.png)`)).toBe(
String.raw`![a](<\\\\server\\share\\my shots\\a b.png>)`,
);
});

it("angle-quotes a POSIX path with spaces and keeps its hash", () => {
expect(repairMarkdownImageDestinations("![chart](/tmp/my charts/growth.png#L2)")).toBe(
"![chart](</tmp/my charts/growth.png#L2>)",
);
});

it("angle-quotes every image on a line and holds balanced parens in the destination", () => {
expect(
repairMarkdownImageDestinations(
String.raw`![a](C:\pics\Screenshot (1).png) ![b](/tmp/x y.png)`,
),
).toBe(String.raw`![a](<C:\\pics\\Screenshot (1).png>) ![b](</tmp/x y.png>)`);
});

it("keeps a parenthesis escaped inside the destination", () => {
expect(repairMarkdownImageDestinations(String.raw`![x](C:\dir with spaces\report\).png)`)).toBe(
String.raw`![x](<C:\\dir with spaces\\report\\).png>)`,
);
});

it("repairs alt text carrying escaped and nested brackets", () => {
expect(repairMarkdownImageDestinations(String.raw`![see \[this\]](/tmp/a b.png)`)).toBe(
String.raw`![see \[this\]](</tmp/a b.png>)`,
);
expect(repairMarkdownImageDestinations("![a [b] c](/tmp/a b.png)")).toBe(
"![a [b] c](</tmp/a b.png>)",
);
});

it("leaves destinations that parse on their own as written", () => {
expect(repairMarkdownImageDestinations("![shot](shots/cmp.png)")).toBe(
"![shot](shots/cmp.png)",
);
expect(repairMarkdownImageDestinations("![shot](<shots/a b.png>)")).toBe(
"![shot](<shots/a b.png>)",
);
expect(repairMarkdownImageDestinations('![shot](shots/a.png "Title")')).toBe(
'![shot](shots/a.png "Title")',
);
expect(repairMarkdownImageDestinations("![shot](shots/'a b'.png)")).toBe(
"![shot](shots/'a b'.png)",
);
});

it("leaves prose without a path separator as written", () => {
expect(repairMarkdownImageDestinations("![figure](one and two)")).toBe(
"![figure](one and two)",
);
});

it("leaves fenced code, inline code, and link syntax alone", () => {
const markdown = [
"```text",
String.raw`![a](C:\dir with spaces\a.png)`,
"```",
"`" + String.raw`![b](C:\d e\b.png)` + "`",
String.raw`[docs](C:\d e\docs.md)`,
].join("\n");
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
});

it("keeps an unclosed fence inert to the end", () => {
const markdown = "```text\n" + String.raw`![a](C:\d e\a.png)`;
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
});

it("leaves a code span that closes on a later line alone", () => {
const markdown = ["`literal", String.raw`![x](C:\d e\x.png)`, "text`"].join("\n");
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
});

it("repairs past a backtick that never closes, because it is literal text", () => {
expect(
repairMarkdownImageDestinations(String.raw`a ` + "`" + String.raw` ![x](/tmp/a b.png)`),
).toBe(String.raw`a ` + "`" + String.raw` ![x](</tmp/a b.png>)`);
});

it("leaves an escaped image marker alone", () => {
const markdown = String.raw`\![x](C:\d e\x.png)`;
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
});

it("leaves every CommonMark HTML block alone", () => {
for (const markdown of [
"<div>\n" + String.raw`![x](/tmp/a b.png)` + "\n</div>",
String.raw`<!-- ![x](/tmp/a b.png) -->`,
"<![CDATA[\n" + String.raw`![x](/tmp/a b.png)` + "\n]]>",
"<table>\n<tr><td>\n" + String.raw`![x](/tmp/a b.png)` + "\n</td></tr>\n</table>",
]) {
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
}
});

it("knows a heading ends the paragraph a tag could not interrupt", () => {
// A standalone tag opens an HTML block only where a block can start, and a
// heading or thematic break above it ends the line's block.
for (const before of ["# H", "---", "H\n==="]) {
const markdown = `${before}\n<x>\n` + String.raw`![x](/tmp/a b.png)`;
expect(repairMarkdownImageDestinations(markdown)).toBe(markdown);
}
// A paragraph or a list item above it is still open, so these are text.
for (const before of ["text", "- item"]) {
expect(
repairMarkdownImageDestinations(`${before}\n<x>\n` + String.raw`![x](/tmp/a b.png)`),
).toBe(`${before}\n<x>\n` + String.raw`![x](</tmp/a b.png>)`);
}
});

it("repairs an image inside a heading", () => {
expect(repairMarkdownImageDestinations(String.raw`# ![x](/tmp/a b.png)`)).toBe(
String.raw`# ![x](</tmp/a b.png>)`,
);
});

it("repairs a paragraph that opens with an inline tag", () => {
// `em` is not a block tag and the tag is not alone on the line, so this is
// a paragraph and the image in it is live Markdown.
expect(repairMarkdownImageDestinations("<em>hi</em> ![x](/tmp/a b.png)")).toBe(
"<em>hi</em> ![x](</tmp/a b.png>)",
);
});

it("closes a fence and repairs past it in a CRLF document", () => {
const markdown = "```text\r\n" + String.raw`![a](C:\d e\a.png)` + "\r\n```\r\n";
expect(repairMarkdownImageDestinations(markdown + String.raw`![b](/tmp/c d.png)`)).toBe(
markdown + String.raw`![b](</tmp/c d.png>)`,
);
});

it("leaves a raw HTML block alone", () => {
const inline = String.raw`<pre>![x](C:\d e\x.png)</pre>`;
expect(repairMarkdownImageDestinations(inline)).toBe(inline);
const block = ["<pre>", String.raw`![x](C:\d e\x.png)`, "</pre>"].join("\n");
expect(repairMarkdownImageDestinations(block)).toBe(block);
});

it("ends an indented code block at the first unindented line", () => {
expect(repairMarkdownImageDestinations(" code\n" + String.raw`![x](/tmp/a b.png)`)).toBe(
" code\n" + String.raw`![x](</tmp/a b.png>)`,
);
});

it("leaves an indented code block alone but repairs a paragraph's own lines", () => {
const code = "text\n\n" + String.raw` ![x](C:\d e\x.png)`;
expect(repairMarkdownImageDestinations(code)).toBe(code);
expect(repairMarkdownImageDestinations("text\n" + String.raw` ![x](/tmp/a b.png)`)).toBe(
"text\n" + String.raw` ![x](</tmp/a b.png>)`,
);
});

it("repairs under a line that only looks like a fence", () => {
// A backtick fence's info string may not contain a backtick, so the line
// below opens no fence and the image under it is live Markdown.
expect(
repairMarkdownImageDestinations("```js`x\n" + String.raw`![a](/tmp/a b.png)` + "\n```"),
).toBe("```js`x\n" + String.raw`![a](</tmp/a b.png>)` + "\n```");
});
});
Loading
Loading