| author | |
| committer | |
| log | c0e09f323ac9f6bf4b9119cbad841db3379588d6 |
| tree | e03bc345ca674a49afa989c13f2d339a6345330c |
| parent | 72c44a847c0a76f33b0d21f47548125b7032ec35 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 329 insertions(+), 40 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+104-31| ... | ... | @@ -24,12 +24,16 @@ import { |
| 24 | 24 | squashAtomCommandFailure, |
| 25 | 25 | type AtomCommandResult, |
| 26 | 26 | } from "@t3tools/client-runtime/state/runtime"; |
| 27 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 27 | import { | |
| 28 | classifyMarkdownImageSource, | |
| 29 | markdownImageSourceFragment, | |
| 30 | } from "@t3tools/client-runtime/markdown-images"; | |
| 28 | 31 | import * as Cause from "effect/Cause"; |
| 29 | 32 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 30 | 33 | import React, { |
| 31 | 34 | Children, |
| 32 | 35 | Suspense, |
| 36 | type CSSProperties, | |
| 33 | 37 | type ClipboardEvent as ReactClipboardEvent, |
| 34 | 38 | type MouseEvent as ReactMouseEvent, |
| 35 | 39 | isValidElement, |
| ... | ... | @@ -90,6 +94,7 @@ import { |
| 90 | 94 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; |
| 91 | 95 | import { |
| 92 | 96 | extractMarkdownLinkHrefs, |
| 97 | isWindowsDrivePathHref, | |
| 93 | 98 | normalizeMarkdownLinkDestination, |
| 94 | 99 | resolveInlineCodeFileLinkMeta, |
| 95 | 100 | resolveMarkdownFileLinkMeta, |
| ... | ... | @@ -149,6 +154,7 @@ interface ChatMarkdownProps { |
| 149 | 154 | lineBreaks?: boolean; |
| 150 | 155 | /** Parse sanitized raw HTML instead of displaying its source text. */ |
| 151 | 156 | parseRawHtml?: boolean; |
| 157 | imageBaseDir?: string | undefined; | |
| 152 | 158 | } |
| 153 | 159 | |
| 154 | 160 | export function canUseMarkdownFileShellActions( |
| ... | ... | @@ -237,27 +243,24 @@ export function orderedListGutterStyle( |
| 237 | 243 | return { "--list-gutter": `${markerWidth + 1}ch` }; |
| 238 | 244 | } |
| 239 | 245 | |
| 240 | type MarkdownHtmlAstNode = { | |
| 246 | type MarkdownImageHastNode = { | |
| 241 | 247 | type?: string; |
| 242 | 248 | tagName?: string; |
| 243 | 249 | properties?: Record<string, unknown>; |
| 244 | children?: MarkdownHtmlAstNode[]; | |
| 250 | children?: MarkdownImageHastNode[]; | |
| 245 | 251 | }; |
| 246 | 252 | |
| 247 | /** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ | |
| 248 | function rehypeNormalizeWindowsImageSrc() { | |
| 249 | return (tree: MarkdownHtmlAstNode) => { | |
| 250 | const visit = (node: MarkdownHtmlAstNode) => { | |
| 253 | /** Carries authored image source metadata through the sanitizer to the image renderer. */ | |
| 254 | function rehypePreserveImageSourceMeta() { | |
| 255 | return (tree: MarkdownImageHastNode) => { | |
| 256 | const visit = (node: MarkdownImageHastNode) => { | |
| 251 | 257 | const src = node.properties?.src; |
| 252 | if ( | |
| 253 | node.type === "element" && | |
| 254 | node.tagName === "img" && | |
| 255 | typeof src === "string" && | |
| 256 | WINDOWS_DRIVE_PATH_REGEX.test(src) | |
| 257 | ) { | |
| 258 | const title = node.properties?.title; | |
| 259 | if (node.type === "element" && node.tagName === "img") { | |
| 258 | 260 | node.properties = { |
| 259 | 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 | 266 | node.children?.forEach(visit); |
| ... | ... | @@ -274,6 +277,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 274 | 277 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), |
| 275 | 278 | code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], |
| 276 | 279 | blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], |
| 280 | img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], | |
| 277 | 281 | }, |
| 278 | 282 | protocols: { |
| 279 | 283 | ...defaultSchema.protocols, |
| ... | ... | @@ -301,7 +305,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 301 | 305 | |
| 302 | 306 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| 303 | 307 | rehypeRaw, |
| 304 | rehypeNormalizeWindowsImageSrc, | |
| 308 | rehypePreserveImageSourceMeta, | |
| 305 | 309 | [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], |
| 306 | 310 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; |
| 307 | 311 | |
| ... | ... | @@ -1045,21 +1049,63 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: |
| 1045 | 1049 | ); |
| 1046 | 1050 | }); |
| 1047 | 1051 | |
| 1048 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = | |
| 1049 | "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; | |
| 1052 | const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; | |
| 1053 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( | |
| 1054 | "h-auto w-auto object-contain", | |
| 1055 | CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, | |
| 1056 | ); | |
| 1057 | ||
| 1058 | function 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 ``; | |
| 1063 | } | |
| 1064 | ||
| 1065 | function 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 | } | |
| 1050 | 1085 | |
| 1051 | // block! outranks the unlayered `.chat-markdown img { display: inline-block }` | |
| 1052 | // rule, keeping workspace images on the same block layout as their placeholder. | |
| 1086 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; | |
| 1053 | 1087 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1054 | 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 | ); |
| 1057 | 1092 | |
| 1058 | function ChatMarkdownImageFallback(props: { readonly alt: string }) { | |
| 1093 | function ChatMarkdownImageFallback(props: { | |
| 1094 | readonly alt: string; | |
| 1095 | readonly copyMarkdown?: string | undefined; | |
| 1096 | }) { | |
| 1059 | 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"> | |
| 1061 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> | |
| 1062 | {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} | |
| 1098 | <span | |
| 1099 | data-markdown-copy={props.copyMarkdown} | |
| 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 | 1109 | </span> |
| 1064 | 1110 | ); |
| 1065 | 1111 | } |
| ... | ... | @@ -1069,6 +1115,9 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop |
| 1069 | 1115 | readonly threadRef: ScopedThreadRef; |
| 1070 | 1116 | readonly path: string; |
| 1071 | 1117 | readonly alt: string; |
| 1118 | readonly copyMarkdown: string; | |
| 1119 | readonly srcFragment: string; | |
| 1120 | readonly style?: CSSProperties | undefined; | |
| 1072 | 1121 | }) { |
| 1073 | 1122 | const assetUrl = useAssetUrlState(props.threadRef.environmentId, { |
| 1074 | 1123 | _tag: "workspace-file", |
| ... | ... | @@ -1078,24 +1127,32 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop |
| 1078 | 1127 | const [failedUrl, setFailedUrl] = useState<string | null>(null); |
| 1079 | 1128 | |
| 1080 | 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 | 1132 | if (assetUrl._tag !== "Success") { |
| 1084 | 1133 | return ( |
| 1085 | 1134 | <span |
| 1135 | data-markdown-copy={props.copyMarkdown} | |
| 1086 | 1136 | role="status" |
| 1087 | 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 | 1147 | return ( |
| 1093 | 1148 | <img |
| 1094 | src={assetUrl.url} | |
| 1149 | src={assetUrl.url + props.srcFragment} | |
| 1095 | 1150 | alt={props.alt} |
| 1151 | data-markdown-copy={props.copyMarkdown} | |
| 1096 | 1152 | loading="lazy" |
| 1097 | 1153 | draggable={false} |
| 1098 | 1154 | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} |
| 1155 | style={props.style} | |
| 1099 | 1156 | onError={() => setFailedUrl(assetUrl.url)} |
| 1100 | 1157 | /> |
| 1101 | 1158 | ); |
| ... | ... | @@ -1636,6 +1693,7 @@ function ChatMarkdown({ |
| 1636 | 1693 | className, |
| 1637 | 1694 | lineBreaks = false, |
| 1638 | 1695 | parseRawHtml = true, |
| 1696 | imageBaseDir, | |
| 1639 | 1697 | }: ChatMarkdownProps) { |
| 1640 | 1698 | const { resolvedTheme } = useTheme(); |
| 1641 | 1699 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| ... | ... | @@ -1729,6 +1787,7 @@ function ChatMarkdown({ |
| 1729 | 1787 | return buildFileLinkParentSuffixByPath(filePaths); |
| 1730 | 1788 | }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); |
| 1731 | 1789 | const markdownUrlTransform = useCallback((href: string) => { |
| 1790 | if (isWindowsDrivePathHref(href)) return href; | |
| 1732 | 1791 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); |
| 1733 | 1792 | }, []); |
| 1734 | 1793 | // Re-emit highlighted content as markdown so copying out of the rendered |
| ... | ... | @@ -2146,10 +2205,19 @@ function ChatMarkdown({ |
| 2146 | 2205 | </code> |
| 2147 | 2206 | ); |
| 2148 | 2207 | }, |
| 2149 | img({ node: _node, title: _title, src, alt, ...props }) { | |
| 2150 | const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; | |
| 2208 | img({ node, title, src, alt, ...props }) { | |
| 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 | 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 | 2221 | if (imageSource._tag === "Direct") { |
| 2154 | 2222 | return ( |
| 2155 | 2223 | <img |
| ... | ... | @@ -2158,6 +2226,7 @@ function ChatMarkdown({ |
| 2158 | 2226 | alt={altText} |
| 2159 | 2227 | loading="lazy" |
| 2160 | 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 | 2236 | threadRef={threadRef} |
| 2168 | 2237 | path={imageSource.path} |
| 2169 | 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 | 2247 | table({ node: _node, ...props }) { |
| 2176 | 2248 | return <MarkdownTable {...props} />; |
| ... | ... | @@ -2213,6 +2285,7 @@ function ChatMarkdown({ |
| 2213 | 2285 | diffThemeName, |
| 2214 | 2286 | fileLinkParentSuffixByPath, |
| 2215 | 2287 | inlineCodeFileLinkMetaByText, |
| 2288 | imageBaseDir, | |
| 2216 | 2289 | isStreaming, |
| 2217 | 2290 | markdownFileLinkMetaByHref, |
| 2218 | 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 | 4 | |
| 5 | 5 | const testState = vi.hoisted(() => ({ |
| 6 | 6 | resources: [] as Array<unknown>, |
| 7 | assetState: "success" as "success" | "loading", | |
| 7 | assetState: "success" as "success" | "loading" | "failure", | |
| 8 | 8 | })); |
| 9 | 9 | |
| 10 | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 11 | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | 12 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 13 | 13 | testState.resources.push(resource); |
| 14 | return testState.assetState === "loading" | |
| 15 | ? { _tag: "Loading" } | |
| 16 | : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; | |
| 14 | if (testState.assetState === "loading") return { _tag: "Loading" }; | |
| 15 | if (testState.assetState === "failure") return { _tag: "Failure" }; | |
| 16 | return { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; | |
| 17 | 17 | }, |
| 18 | 18 | })); |
| 19 | 19 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| ... | ... | @@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ |
| 42 | 42 | })); |
| 43 | 43 | |
| 44 | 44 | import ChatMarkdown from "./ChatMarkdown"; |
| 45 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; | |
| 45 | 46 | |
| 46 | 47 | const threadRef = { |
| 47 | 48 | environmentId: EnvironmentId.make("env-windows"), |
| ... | ... | @@ -58,12 +59,60 @@ function renderWithoutThread(markdown: string): string { |
| 58 | 59 | return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />); |
| 59 | 60 | } |
| 60 | 61 | |
| 62 | function renderFilePreview(cwd: string, relativePath: string): string { | |
| 63 | return renderToStaticMarkup( | |
| 64 | <FileMarkdownPreview | |
| 65 | cwd={cwd} | |
| 66 | relativePath={relativePath} | |
| 67 | text="" | |
| 68 | threadRef={threadRef} | |
| 69 | />, | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | function copiedMarkdownFrom(html: string): string { | |
| 74 | const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll("&quot;", '"'); | |
| 75 | expect(copy).toBeDefined(); | |
| 76 | return copy ?? ""; | |
| 77 | } | |
| 78 | ||
| 79 | function 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 | ||
| 61 | 90 | describe("ChatMarkdown workspace images", () => { |
| 62 | 91 | beforeEach(() => { |
| 63 | 92 | testState.resources = []; |
| 64 | 93 | testState.assetState = "success"; |
| 65 | 94 | }); |
| 66 | 95 | |
| 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 | 116 | it("loads every Windows workspace path form through a signed asset URL", () => { |
| 68 | 117 | const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; |
| 69 | 118 | const html = render( |
| ... | ... | @@ -108,13 +157,116 @@ describe("ChatMarkdown workspace images", () => { |
| 108 | 157 | expect(html).toContain("https://signed.test/workspace-image.svg"); |
| 109 | 158 | }); |
| 110 | 159 | |
| 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 | " ", | |
| 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(""); | |
| 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(""); | |
| 216 | expect(copiedMarkdownFrom(html)).toBe(""); | |
| 217 | return; | |
| 218 | } | |
| 219 | ||
| 220 | testState.assetState = scenario; | |
| 221 | const html = render(""); | |
| 222 | ||
| 223 | expect(copiedMarkdownFrom(html)).toBe(""); | |
| 224 | }, | |
| 225 | ); | |
| 226 | ||
| 227 | it("copies an authored title with a workspace image", () => { | |
| 228 | const html = render(''); | |
| 229 | ||
| 230 | expect(copiedMarkdownFrom(html)).toBe(''); | |
| 231 | }); | |
| 232 | ||
| 233 | it("escapes double quotes in an authored image title", () => { | |
| 234 | const html = render(``); | |
| 235 | ||
| 236 | expect(copiedMarkdownFrom(html)).toBe(''); | |
| 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``; | |
| 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``, | |
| 258 | ); | |
| 259 | }); | |
| 260 | ||
| 261 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { | |
| 112 | 262 | testState.assetState = "loading"; |
| 113 | 263 | |
| 114 | 264 | const html = render(""); |
| 265 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; | |
| 115 | 266 | |
| 116 | 267 | expect(html).toContain('aria-label="Loading image"'); |
| 117 | 268 | expect(html).not.toContain("animate-pulse"); |
| 269 | expect(className?.split(" ")).toContain("w-64"); | |
| 118 | 270 | }); |
| 119 | 271 | |
| 120 | 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 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import ChatMarkdown from "~/components/ChatMarkdown"; | |
| 4 | import { resolvePathLinkTarget } from "~/terminal-links"; | |
| 5 | ||
| 6 | export 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 | 18 | |
| 19 | 19 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 20 | 20 | import { useAssetUrlState } from "~/assets/assetUrls"; |
| 21 | import ChatMarkdown from "~/components/ChatMarkdown"; | |
| 22 | 21 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | 22 | import { useRemoteOpenState } from "~/remoteOpen"; |
| 24 | 23 | import { useClientSettings } from "~/hooks/useSettings"; |
| ... | ... | @@ -42,6 +41,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; |
| 42 | 41 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 43 | 42 | |
| 44 | 43 | import FileBrowserPanel from "./FileBrowserPanel"; |
| 44 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | |
| 45 | 45 | import { |
| 46 | 46 | type FileCommentAnnotationEntry, |
| 47 | 47 | type FileCommentAnnotationGroup, |
| ... | ... | @@ -727,11 +727,11 @@ function RenderedMarkdownSurface({ |
| 727 | 727 | |
| 728 | 728 | return ( |
| 729 | 729 | <ScrollArea className="min-h-0 flex-1"> |
| 730 | <ChatMarkdown | |
| 730 | <FileMarkdownPreview | |
| 731 | 731 | text={contents} |
| 732 | 732 | cwd={cwd} |
| 733 | relativePath={relativePath} | |
| 733 | 734 | threadRef={threadRef} |
| 734 | className="mx-auto max-w-4xl px-6 py-5" | |
| 735 | 735 | onTaskListChange={({ markerOffset, checked }) => { |
| 736 | 736 | const currentContents = |
| 737 | 737 | getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? |
apps/web/src/markdown-links.test.ts+11| ... | ... | @@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | extractMarkdownLinkHrefs, |
| 8 | isWindowsDrivePathHref, | |
| 8 | 9 | resolveInlineCodeFileLinkMeta, |
| 9 | 10 | resolveMarkdownFileLinkMeta, |
| 10 | 11 | resolveMarkdownFileLinkTarget, |
| ... | ... | @@ -13,6 +14,16 @@ import { |
| 13 | 14 | shouldOpenMarkdownFileLinkInEditor, |
| 14 | 15 | } from "./markdown-links"; |
| 15 | 16 | |
| 17 | describe("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 | ||
| 16 | 27 | function renderMarkdownLinkHref(markdown: string): string | undefined { |
| 17 | 28 | let renderedHref: string | undefined; |
| 18 | 29 | renderToStaticMarkup( |
apps/web/src/markdown-links.ts+4| ... | ... | @@ -84,6 +84,10 @@ function safeDecode(value: string): string { |
| 84 | 84 | } |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | export function isWindowsDrivePathHref(href: string): boolean { | |
| 88 | return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); | |
| 89 | } | |
| 90 | ||
| 87 | 91 | function unwrapMarkdownLinkDestination(value: string): string { |
| 88 | 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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { classifyMarkdownImageSource } from "./markdownImages.js"; | |
| 3 | import { classifyMarkdownImageSource, markdownImageSourceFragment } from "./markdownImages.js"; | |
| 4 | 4 | |
| 5 | 5 | describe("classifyMarkdownImageSource", () => { |
| 6 | 6 | it.each([ |
| ... | ... | @@ -60,3 +60,12 @@ describe("classifyMarkdownImageSource", () => { |
| 60 | 60 | expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" }); |
| 61 | 61 | }); |
| 62 | 62 | }); |
| 63 | ||
| 64 | describe("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 | 20 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | export function markdownImageSourceFragment(source: string): string { | |
| 24 | const normalizedSource = normalizeSource(source); | |
| 25 | const hashIndex = normalizedSource.indexOf("#"); | |
| 26 | return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; | |
| 27 | } | |
| 28 | ||
| 23 | 29 | function normalizeWindowsDrivePath(value: string): string { |
| 24 | 30 | return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; |
| 25 | 31 | } |