authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-08-29 12:21:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 12:21:36-07:00
logc0e09f323ac9f6bf4b9119cbad841db3379588d6
treee03bc345ca674a49afa989c13f2d339a6345330c
parent72c44a847c0a76f33b0d21f47548125b7032ec35
signature Signed by PGP key B5690EEEBB952194

fix(web): render nested markdown images correctly (#8501)


8 files changed, 329 insertions(+), 40 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+104-31
...@@ -24,12 +24,16 @@ import {...@@ -24,12 +24,16 @@ import {
24 squashAtomCommandFailure,24 squashAtomCommandFailure,
25 type AtomCommandResult,25 type AtomCommandResult,
26} from "@t3tools/client-runtime/state/runtime";26} from "@t3tools/client-runtime/state/runtime";
27import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";27import {
28 classifyMarkdownImageSource,
29 markdownImageSourceFragment,
30} from "@t3tools/client-runtime/markdown-images";
28import * as Cause from "effect/Cause";31import * as Cause from "effect/Cause";
29import { AsyncResult } from "effect/unstable/reactivity";32import { AsyncResult } from "effect/unstable/reactivity";
30import React, {33import React, {
31 Children,34 Children,
32 Suspense,35 Suspense,
36 type CSSProperties,
33 type ClipboardEvent as ReactClipboardEvent,37 type ClipboardEvent as ReactClipboardEvent,
34 type MouseEvent as ReactMouseEvent,38 type MouseEvent as ReactMouseEvent,
35 isValidElement,39 isValidElement,
...@@ -90,6 +94,7 @@ import {...@@ -90,6 +94,7 @@ import {
90import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";94import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
91import {95import {
92 extractMarkdownLinkHrefs,96 extractMarkdownLinkHrefs,
97 isWindowsDrivePathHref,
93 normalizeMarkdownLinkDestination,98 normalizeMarkdownLinkDestination,
94 resolveInlineCodeFileLinkMeta,99 resolveInlineCodeFileLinkMeta,
95 resolveMarkdownFileLinkMeta,100 resolveMarkdownFileLinkMeta,
...@@ -149,6 +154,7 @@ interface ChatMarkdownProps {...@@ -149,6 +154,7 @@ interface ChatMarkdownProps {
149 lineBreaks?: boolean;154 lineBreaks?: boolean;
150 /** Parse sanitized raw HTML instead of displaying its source text. */155 /** Parse sanitized raw HTML instead of displaying its source text. */
151 parseRawHtml?: boolean;156 parseRawHtml?: boolean;
157 imageBaseDir?: string | undefined;
152}158}
153159
154export function canUseMarkdownFileShellActions(160export function canUseMarkdownFileShellActions(
...@@ -237,27 +243,24 @@ export function orderedListGutterStyle(...@@ -237,27 +243,24 @@ export function orderedListGutterStyle(
237 return { "--list-gutter": `${markerWidth + 1}ch` };243 return { "--list-gutter": `${markerWidth + 1}ch` };
238}244}
239245
240type MarkdownHtmlAstNode = {246type MarkdownImageHastNode = {
241 type?: string;247 type?: string;
242 tagName?: string;248 tagName?: string;
243 properties?: Record<string, unknown>;249 properties?: Record<string, unknown>;
244 children?: MarkdownHtmlAstNode[];250 children?: MarkdownImageHastNode[];
245};251};
246252
247/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */253/** Carries authored image source metadata through the sanitizer to the image renderer. */
248function rehypeNormalizeWindowsImageSrc() {254function rehypePreserveImageSourceMeta() {
249 return (tree: MarkdownHtmlAstNode) => {255 return (tree: MarkdownImageHastNode) => {
250 const visit = (node: MarkdownHtmlAstNode) => {256 const visit = (node: MarkdownImageHastNode) => {
251 const src = node.properties?.src;257 const src = node.properties?.src;
252 if (258 const title = node.properties?.title;
253 node.type === "element" &&259 if (node.type === "element" && node.tagName === "img") {
254 node.tagName === "img" &&
255 typeof src === "string" &&
256 WINDOWS_DRIVE_PATH_REGEX.test(src)
257 ) {
258 node.properties = {260 node.properties = {
259 ...node.properties,261 ...node.properties,
260 src: `file:///${src.replaceAll("\\", "/")}`,262 ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}),
263 ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}),
261 };264 };
262 }265 }
263 node.children?.forEach(visit);266 node.children?.forEach(visit);
...@@ -274,6 +277,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -274,6 +277,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
274 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),277 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
275 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],278 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
276 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],279 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
280 img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
277 },281 },
278 protocols: {282 protocols: {
279 ...defaultSchema.protocols,283 ...defaultSchema.protocols,
...@@ -301,7 +305,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [...@@ -301,7 +305,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
301305
302const CHAT_MARKDOWN_REHYPE_PLUGINS = [306const CHAT_MARKDOWN_REHYPE_PLUGINS = [
303 rehypeRaw,307 rehypeRaw,
304 rehypeNormalizeWindowsImageSrc,308 rehypePreserveImageSourceMeta,
305 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],309 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
306] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;310] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
307311
...@@ -1045,21 +1049,63 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:...@@ -1045,21 +1049,63 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
1045 );1049 );
1046});1050});
10471051
1048const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME =1052const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]";
1049 "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain";1053const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn(
1054 "h-auto w-auto object-contain",
1055 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,
1056);
1057
1058function markdownImageCopy(alt: string, src: string, title: string | undefined): string {
1059 const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
1060 const titleSuffix =
1061 title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1062 return `![${escapedAlt}](${src}${titleSuffix})`;
1063}
1064
1065function authoredImageSizeStyle(
1066 width: string | number | undefined,
1067 height: string | number | undefined,
1068): CSSProperties | undefined {
1069 const parsedWidth = Number(width);
1070 const parsedHeight = Number(height);
1071 const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0;
1072 const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0;
1073 if (hasWidth && hasHeight) {
1074 return {
1075 width: parsedWidth,
1076 height: "auto",
1077 aspectRatio: `${parsedWidth} / ${parsedHeight}`,
1078 maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`,
1079 };
1080 }
1081 if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` };
1082 if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` };
1083 return undefined;
1084}
10501085
1051// block! outranks the unlayered `.chat-markdown img { display: inline-block }`1086const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!";
1052// rule, keeping workspace images on the same block layout as their placeholder.
1053const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(1087const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1054 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,1088 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1055 "my-1 block! rounded-lg border border-border/40",1089 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1090 "rounded-lg border border-border/40",
1056);1091);
10571092
1058function ChatMarkdownImageFallback(props: { readonly alt: string }) {1093function ChatMarkdownImageFallback(props: {
1094 readonly alt: string;
1095 readonly copyMarkdown?: string | undefined;
1096}) {
1059 return (1097 return (
1060 <span className="my-1 inline-flex items-center gap-1.5 rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground">1098 <span
1061 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />1099 data-markdown-copy={props.copyMarkdown}
1062 {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"}1100 className={cn(
1101 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1102 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
1103 )}
1104 >
1105 <span className="inline-flex items-center gap-1.5">
1106 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1107 {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"}
1108 </span>
1063 </span>1109 </span>
1064 );1110 );
1065}1111}
...@@ -1069,6 +1115,9 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop...@@ -1069,6 +1115,9 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
1069 readonly threadRef: ScopedThreadRef;1115 readonly threadRef: ScopedThreadRef;
1070 readonly path: string;1116 readonly path: string;
1071 readonly alt: string;1117 readonly alt: string;
1118 readonly copyMarkdown: string;
1119 readonly srcFragment: string;
1120 readonly style?: CSSProperties | undefined;
1072}) {1121}) {
1073 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {1122 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {
1074 _tag: "workspace-file",1123 _tag: "workspace-file",
...@@ -1078,24 +1127,32 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop...@@ -1078,24 +1127,32 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
1078 const [failedUrl, setFailedUrl] = useState<string | null>(null);1127 const [failedUrl, setFailedUrl] = useState<string | null>(null);
10791128
1080 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {1129 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
1081 return <ChatMarkdownImageFallback alt={props.alt} />;1130 return <ChatMarkdownImageFallback alt={props.alt} copyMarkdown={props.copyMarkdown} />;
1082 }1131 }
1083 if (assetUrl._tag !== "Success") {1132 if (assetUrl._tag !== "Success") {
1084 return (1133 return (
1085 <span1134 <span
1135 data-markdown-copy={props.copyMarkdown}
1086 role="status"1136 role="status"
1087 aria-label="Loading image"1137 aria-label="Loading image"
1088 className="my-1 block aspect-video w-full max-w-[30rem] rounded-lg bg-muted/60"1138 className={cn(
1139 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1140 "aspect-video w-64 max-w-full rounded-lg bg-muted/60",
1141 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,
1142 )}
1143 style={props.style}
1089 />1144 />
1090 );1145 );
1091 }1146 }
1092 return (1147 return (
1093 <img1148 <img
1094 src={assetUrl.url}1149 src={assetUrl.url + props.srcFragment}
1095 alt={props.alt}1150 alt={props.alt}
1151 data-markdown-copy={props.copyMarkdown}
1096 loading="lazy"1152 loading="lazy"
1097 draggable={false}1153 draggable={false}
1098 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}1154 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
1155 style={props.style}
1099 onError={() => setFailedUrl(assetUrl.url)}1156 onError={() => setFailedUrl(assetUrl.url)}
1100 />1157 />
1101 );1158 );
...@@ -1636,6 +1693,7 @@ function ChatMarkdown({...@@ -1636,6 +1693,7 @@ function ChatMarkdown({
1636 className,1693 className,
1637 lineBreaks = false,1694 lineBreaks = false,
1638 parseRawHtml = true,1695 parseRawHtml = true,
1696 imageBaseDir,
1639}: ChatMarkdownProps) {1697}: ChatMarkdownProps) {
1640 const { resolvedTheme } = useTheme();1698 const { resolvedTheme } = useTheme();
1641 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1699 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
...@@ -1729,6 +1787,7 @@ function ChatMarkdown({...@@ -1729,6 +1787,7 @@ function ChatMarkdown({
1729 return buildFileLinkParentSuffixByPath(filePaths);1787 return buildFileLinkParentSuffixByPath(filePaths);
1730 }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);1788 }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
1731 const markdownUrlTransform = useCallback((href: string) => {1789 const markdownUrlTransform = useCallback((href: string) => {
1790 if (isWindowsDrivePathHref(href)) return href;
1732 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);1791 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
1733 }, []);1792 }, []);
1734 // Re-emit highlighted content as markdown so copying out of the rendered1793 // Re-emit highlighted content as markdown so copying out of the rendered
...@@ -2146,10 +2205,19 @@ function ChatMarkdown({...@@ -2146,10 +2205,19 @@ function ChatMarkdown({
2146 </code>2205 </code>
2147 );2206 );
2148 },2207 },
2149 img({ node: _node, title: _title, src, alt, ...props }) {2208 img({ node, title, src, alt, ...props }) {
2150 const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : "";2209 const localSrc = node?.properties?.dataLocalSrc;
2210 const markdownTitle = node?.properties?.dataMarkdownTitle;
2211 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
2212 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
2213 const srcString =
2214 typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";
2215 const classifiedSrc =
2216 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
2151 const altText = alt ?? "";2217 const altText = alt ?? "";
2152 const imageSource = classifyMarkdownImageSource(srcString, cwd);2218 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
2219 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
2220 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2153 if (imageSource._tag === "Direct") {2221 if (imageSource._tag === "Direct") {
2154 return (2222 return (
2155 <img2223 <img
...@@ -2158,6 +2226,7 @@ function ChatMarkdown({...@@ -2158,6 +2226,7 @@ function ChatMarkdown({
2158 alt={altText}2226 alt={altText}
2159 loading="lazy"2227 loading="lazy"
2160 className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)}2228 className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)}
2229 style={authoredSizeStyle}
2161 />2230 />
2162 );2231 );
2163 }2232 }
...@@ -2167,10 +2236,13 @@ function ChatMarkdown({...@@ -2167,10 +2236,13 @@ function ChatMarkdown({
2167 threadRef={threadRef}2236 threadRef={threadRef}
2168 path={imageSource.path}2237 path={imageSource.path}
2169 alt={altText}2238 alt={altText}
2239 copyMarkdown={copyMarkdown}
2240 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2241 style={authoredSizeStyle}
2170 />2242 />
2171 );2243 );
2172 }2244 }
2173 return <ChatMarkdownImageFallback alt={altText} />;2245 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} />;
2174 },2246 },
2175 table({ node: _node, ...props }) {2247 table({ node: _node, ...props }) {
2176 return <MarkdownTable {...props} />;2248 return <MarkdownTable {...props} />;
...@@ -2213,6 +2285,7 @@ function ChatMarkdown({...@@ -2213,6 +2285,7 @@ function ChatMarkdown({
2213 diffThemeName,2285 diffThemeName,
2214 fileLinkParentSuffixByPath,2286 fileLinkParentSuffixByPath,
2215 inlineCodeFileLinkMetaByText,2287 inlineCodeFileLinkMetaByText,
2288 imageBaseDir,
2216 isStreaming,2289 isStreaming,
2217 markdownFileLinkMetaByHref,2290 markdownFileLinkMetaByHref,
2218 onTaskListChange,2291 onTaskListChange,
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+157-5
...@@ -4,16 +4,16 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";...@@ -4,16 +4,16 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
5const testState = vi.hoisted(() => ({5const testState = vi.hoisted(() => ({
6 resources: [] as Array<unknown>,6 resources: [] as Array<unknown>,
7 assetState: "success" as "success" | "loading",7 assetState: "success" as "success" | "loading" | "failure",
8}));8}));
99
10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
11vi.mock("../assets/assetUrls", () => ({11vi.mock("../assets/assetUrls", () => ({
12 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {12 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
13 testState.resources.push(resource);13 testState.resources.push(resource);
14 return testState.assetState === "loading"14 if (testState.assetState === "loading") return { _tag: "Loading" };
15 ? { _tag: "Loading" }15 if (testState.assetState === "failure") return { _tag: "Failure" };
16 : { _tag: "Success", url: "https://signed.test/workspace-image.svg" };16 return { _tag: "Success", url: "https://signed.test/workspace-image.svg" };
17 },17 },
18}));18}));
19vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));19vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
...@@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({...@@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({
42}));42}));
4343
44import ChatMarkdown from "./ChatMarkdown";44import ChatMarkdown from "./ChatMarkdown";
45import { FileMarkdownPreview } from "./files/FileMarkdownPreview";
4546
46const threadRef = {47const threadRef = {
47 environmentId: EnvironmentId.make("env-windows"),48 environmentId: EnvironmentId.make("env-windows"),
...@@ -58,12 +59,60 @@ function renderWithoutThread(markdown: string): string {...@@ -58,12 +59,60 @@ function renderWithoutThread(markdown: string): string {
58 return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />);59 return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />);
59}60}
6061
62function renderFilePreview(cwd: string, relativePath: string): string {
63 return renderToStaticMarkup(
64 <FileMarkdownPreview
65 cwd={cwd}
66 relativePath={relativePath}
67 text="![diagram](images/diagram.png)"
68 threadRef={threadRef}
69 />,
70 );
71}
72
73function copiedMarkdownFrom(html: string): string {
74 const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll("&quot;", '"');
75 expect(copy).toBeDefined();
76 return copy ?? "";
77}
78
79function firstInlineStyle(html: string): Record<string, string> {
80 const style = /style="([^"]+)"/.exec(html)?.[1];
81 expect(style).toBeDefined();
82 return Object.fromEntries(
83 (style ?? "").split(";").map((declaration) => {
84 const separator = declaration.indexOf(":");
85 return [declaration.slice(0, separator), declaration.slice(separator + 1)];
86 }),
87 );
88}
89
61describe("ChatMarkdown workspace images", () => {90describe("ChatMarkdown workspace images", () => {
62 beforeEach(() => {91 beforeEach(() => {
63 testState.resources = [];92 testState.resources = [];
64 testState.assetState = "success";93 testState.assetState = "success";
65 });94 });
6695
96 it.each([
97 ["/workspace/project", "docs/README.md", "/workspace/project/docs/images/diagram.png"],
98 [
99 "C:\\Users\\shawn\\project",
100 "docs\\README.md",
101 "C:\\Users\\shawn\\project\\docs\\images\\diagram.png",
102 ],
103 ["/workspace/project", "README.md", "/workspace/project/images/diagram.png"],
104 ])("resolves images beside a nested file in %s", (cwd, relativePath, expectedPath) => {
105 renderFilePreview(cwd, relativePath);
106
107 expect(testState.resources).toEqual([
108 {
109 _tag: "workspace-file",
110 threadId: threadRef.threadId,
111 path: expectedPath,
112 },
113 ]);
114 });
115
67 it("loads every Windows workspace path form through a signed asset URL", () => {116 it("loads every Windows workspace path form through a signed asset URL", () => {
68 const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";117 const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";
69 const html = render(118 const html = render(
...@@ -108,13 +157,116 @@ describe("ChatMarkdown workspace images", () => {...@@ -108,13 +157,116 @@ describe("ChatMarkdown workspace images", () => {
108 expect(html).toContain("https://signed.test/workspace-image.svg");157 expect(html).toContain("https://signed.test/workspace-image.svg");
109 });158 });
110159
111 it("uses a static placeholder while a signed asset URL loads", () => {160 it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => {
161 const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">';
162 const loadedStyle = firstInlineStyle(render(markdown));
163 testState.assetState = "loading";
164 const loadingStyle = firstInlineStyle(render(markdown));
165
166 expect(loadedStyle).toMatchObject({
167 width: "96px",
168 height: "auto",
169 "aspect-ratio": "96 / 128",
170 "max-width": "min(100%, 30rem, 22.5rem)",
171 });
172 expect(loadingStyle).toEqual(loadedStyle);
173 });
174
175 it.each([
176 ["width", "max-width", "min(100%, 30rem, 300px)"],
177 ["height", "max-height", "min(30rem, 300px)"],
178 ])("treats a lone authored %s as a cap", (axis, constraint, expectedValue) => {
179 const markdown = `<img src=".t3/workspace-image.svg" alt="sized" ${axis}="300">`;
180 const loadedStyle = firstInlineStyle(render(markdown));
181
182 expect(loadedStyle).not.toHaveProperty(axis);
183 expect(loadedStyle).toHaveProperty(constraint, expectedValue);
184 });
185
186 it("keeps all images baseline-aligned and workspace images inline", () => {
187 const html = render(
188 "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)",
189 );
190 const classNames = Array.from(html.matchAll(/<img[^>]*class="([^"]*)"/g), (match) =>
191 match[1]?.split(" "),
192 );
193
194 expect(classNames).toHaveLength(2);
195 expect(classNames[1]).toContain("inline-block!");
196
197 const centeredHtml = render(
198 '<p align="center"><img src=".t3/workspace-image.svg" alt="logo"></p>',
199 );
200 const centeredClassName = /<img[^>]*class="([^"]*)"/.exec(centeredHtml)?.[1];
201
202 expect(centeredClassName?.split(" ")).toContain("inline-block!");
203 });
204
205 it("retains an authored SVG fragment on the signed URL", () => {
206 const html = render("![logo](icons.svg#logo)");
207
208 expect(html).toContain('src="https://signed.test/workspace-image.svg#logo"');
209 });
210
211 it.each(["success", "loading", "failure", "no-thread"] as const)(
212 "copies the authored workspace source (%s)",
213 (scenario) => {
214 if (scenario === "no-thread") {
215 const html = renderWithoutThread("![diagram](images/diagram.png)");
216 expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png)");
217 return;
218 }
219
220 testState.assetState = scenario;
221 const html = render("![diagram](images/diagram.png#preview)");
222
223 expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png#preview)");
224 },
225 );
226
227 it("copies an authored title with a workspace image", () => {
228 const html = render('![logo](images/logo.svg "My Title")');
229
230 expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My Title")');
231 });
232
233 it("escapes double quotes in an authored image title", () => {
234 const html = render(`![logo](images/logo.svg 'My "Title"')`);
235
236 expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My \\"Title\\"")');
237 });
238
239 it("escapes a closing bracket in authored image alt text", () => {
240 const markdown = String.raw`![build\] badge](badge.svg)`;
241
242 expect(copiedMarkdownFrom(render(markdown))).toBe(markdown);
243 });
244
245 it("escapes a literal backslash in authored image alt text", () => {
246 const markdown = String.raw`![folder\\name](badge.svg)`;
247
248 expect(copiedMarkdownFrom(render(markdown))).toBe(markdown);
249 });
250
251 it("escapes a literal backslash before a quote in an authored image title", () => {
252 const html = render(
253 String.raw`<img src="images/logo.svg" alt="logo" title="Path \&quot;Title\&quot;">`,
254 );
255
256 expect(copiedMarkdownFrom(html)).toBe(
257 String.raw`![logo](images/logo.svg "Path \\\"Title\\\"")`,
258 );
259 });
260
261 it("uses a static bounded-width placeholder while a signed asset URL loads", () => {
112 testState.assetState = "loading";262 testState.assetState = "loading";
113263
114 const html = render("![loading](.t3/workspace-image.svg)");264 const html = render("![loading](.t3/workspace-image.svg)");
265 const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1];
115266
116 expect(html).toContain('aria-label="Loading image"');267 expect(html).toContain('aria-label="Loading image"');
117 expect(html).not.toContain("animate-pulse");268 expect(html).not.toContain("animate-pulse");
269 expect(className?.split(" ")).toContain("w-64");
118 });270 });
119271
120 it("never passes a workspace source to a raw image when thread context is unavailable", () => {272 it("never passes a workspace source to a raw image when thread context is unavailable", () => {
apps/web/src/components/files/FileMarkdownPreview.tsx created+34
...@@ -0,0 +1,34 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import ChatMarkdown from "~/components/ChatMarkdown";
4import { resolvePathLinkTarget } from "~/terminal-links";
5
6export function FileMarkdownPreview(props: {
7 readonly cwd: string;
8 readonly relativePath: string;
9 readonly text: string;
10 readonly threadRef: ScopedThreadRef;
11 readonly onTaskListChange?:
12 | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void)
13 | undefined;
14}) {
15 const lastSeparator = Math.max(
16 props.relativePath.lastIndexOf("/"),
17 props.relativePath.lastIndexOf("\\"),
18 );
19 const imageBaseDir =
20 lastSeparator >= 0
21 ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd)
22 : props.cwd;
23
24 return (
25 <ChatMarkdown
26 text={props.text}
27 cwd={props.cwd}
28 imageBaseDir={imageBaseDir}
29 threadRef={props.threadRef}
30 className="mx-auto max-w-4xl px-6 py-5"
31 onTaskListChange={props.onTaskListChange}
32 />
33 );
34}
apps/web/src/components/files/FilePreviewPanel.tsx+3-3
...@@ -18,7 +18,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";...@@ -18,7 +18,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1818
19import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";19import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20import { useAssetUrlState } from "~/assets/assetUrls";20import { useAssetUrlState } from "~/assets/assetUrls";
21import ChatMarkdown from "~/components/ChatMarkdown";
22import { OpenInPicker } from "~/components/chat/OpenInPicker";21import { OpenInPicker } from "~/components/chat/OpenInPicker";
23import { useRemoteOpenState } from "~/remoteOpen";22import { useRemoteOpenState } from "~/remoteOpen";
24import { useClientSettings } from "~/hooks/useSettings";23import { useClientSettings } from "~/hooks/useSettings";
...@@ -42,6 +41,7 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -42,6 +41,7 @@ import { useAtomCommand } from "~/state/use-atom-command";
42import { useAtomQueryRunner } from "~/state/use-atom-query-runner";41import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
4342
44import FileBrowserPanel from "./FileBrowserPanel";43import FileBrowserPanel from "./FileBrowserPanel";
44import { FileMarkdownPreview } from "./FileMarkdownPreview";
45import {45import {
46 type FileCommentAnnotationEntry,46 type FileCommentAnnotationEntry,
47 type FileCommentAnnotationGroup,47 type FileCommentAnnotationGroup,
...@@ -727,11 +727,11 @@ function RenderedMarkdownSurface({...@@ -727,11 +727,11 @@ function RenderedMarkdownSurface({
727727
728 return (728 return (
729 <ScrollArea className="min-h-0 flex-1">729 <ScrollArea className="min-h-0 flex-1">
730 <ChatMarkdown730 <FileMarkdownPreview
731 text={contents}731 text={contents}
732 cwd={cwd}732 cwd={cwd}
733 relativePath={relativePath}
733 threadRef={threadRef}734 threadRef={threadRef}
734 className="mx-auto max-w-4xl px-6 py-5"
735 onTaskListChange={({ markerOffset, checked }) => {735 onTaskListChange={({ markerOffset, checked }) => {
736 const currentContents =736 const currentContents =
737 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??737 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
apps/web/src/markdown-links.test.ts+11
...@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";...@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
55
6import {6import {
7 extractMarkdownLinkHrefs,7 extractMarkdownLinkHrefs,
8 isWindowsDrivePathHref,
8 resolveInlineCodeFileLinkMeta,9 resolveInlineCodeFileLinkMeta,
9 resolveMarkdownFileLinkMeta,10 resolveMarkdownFileLinkMeta,
10 resolveMarkdownFileLinkTarget,11 resolveMarkdownFileLinkTarget,
...@@ -13,6 +14,16 @@ import {...@@ -13,6 +14,16 @@ import {
13 shouldOpenMarkdownFileLinkInEditor,14 shouldOpenMarkdownFileLinkInEditor,
14} from "./markdown-links";15} from "./markdown-links";
1516
17describe("isWindowsDrivePathHref", () => {
18 it.each([
19 ["C:\\repo\\image.png", true],
20 ["C:%5Crepo%5Cimage.png", true],
21 ["https://example.com/image.png", false],
22 ])("classifies %s as %s", (href, expected) => {
23 expect(isWindowsDrivePathHref(href)).toBe(expected);
24 });
25});
26
16function renderMarkdownLinkHref(markdown: string): string | undefined {27function renderMarkdownLinkHref(markdown: string): string | undefined {
17 let renderedHref: string | undefined;28 let renderedHref: string | undefined;
18 renderToStaticMarkup(29 renderToStaticMarkup(
apps/web/src/markdown-links.ts+4
...@@ -84,6 +84,10 @@ function safeDecode(value: string): string {...@@ -84,6 +84,10 @@ function safeDecode(value: string): string {
84 }84 }
85}85}
8686
87export function isWindowsDrivePathHref(href: string): boolean {
88 return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href));
89}
90
87function unwrapMarkdownLinkDestination(value: string): string {91function unwrapMarkdownLinkDestination(value: string): string {
88 return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;92 return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
89}93}
packages/client-runtime/src/markdownImages.test.ts+10-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { classifyMarkdownImageSource } from "./markdownImages.js";3import { classifyMarkdownImageSource, markdownImageSourceFragment } from "./markdownImages.js";
44
5describe("classifyMarkdownImageSource", () => {5describe("classifyMarkdownImageSource", () => {
6 it.each([6 it.each([
...@@ -60,3 +60,12 @@ describe("classifyMarkdownImageSource", () => {...@@ -60,3 +60,12 @@ describe("classifyMarkdownImageSource", () => {
60 expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" });60 expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" });
61 });61 });
62});62});
63
64describe("markdownImageSourceFragment", () => {
65 it.each([
66 ["<icons.svg?version=2#logo>", "#logo"],
67 ["icons.svg?version=2", ""],
68 ])("extracts %s as %s", (source, fragment) => {
69 expect(markdownImageSourceFragment(source)).toBe(fragment);
70 });
71});
packages/client-runtime/src/markdownImages.ts+6
...@@ -20,6 +20,12 @@ function normalizeSource(value: string): string {...@@ -20,6 +20,12 @@ function normalizeSource(value: string): string {
20 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;20 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
21}21}
2222
23export function markdownImageSourceFragment(source: string): string {
24 const normalizedSource = normalizeSource(source);
25 const hashIndex = normalizedSource.indexOf("#");
26 return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : "";
27}
28
23function normalizeWindowsDrivePath(value: string): string {29function normalizeWindowsDrivePath(value: string): string {
24 return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value;30 return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value;
25}31}