| 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,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"; |
| 27 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | 27 | import { |
| 28 | classifyMarkdownImageSource, | ||
| 29 | markdownImageSourceFragment, | ||
| 30 | } from "@t3tools/client-runtime/markdown-images"; | ||
| 28 | import * as Cause from "effect/Cause"; | 31 | import * as Cause from "effect/Cause"; |
| 29 | import { AsyncResult } from "effect/unstable/reactivity"; | 32 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 30 | import React, { | 33 | import 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 { |
| 90 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | 94 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; |
| 91 | import { | 95 | import { |
| 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 | } |
| 153 | 159 | ||
| 154 | export function canUseMarkdownFileShellActions( | 160 | export 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 | } |
| 239 | 245 | ||
| 240 | type MarkdownHtmlAstNode = { | 246 | type 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 | }; |
| 246 | 252 | ||
| 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. */ |
| 248 | function rehypeNormalizeWindowsImageSrc() { | 254 | function 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 = [ |
| 301 | 305 | ||
| 302 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | 306 | const 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"]>; |
| 307 | 311 | ||
| ... | @@ -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 | }); |
| 1047 | 1051 | ||
| 1048 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = | 1052 | const 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"; | 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 }` | 1086 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; |
| 1052 | // rule, keeping workspace images on the same block layout as their placeholder. | ||
| 1053 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( | 1087 | const 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 | ); |
| 1057 | 1092 | ||
| 1058 | function ChatMarkdownImageFallback(props: { readonly alt: string }) { | 1093 | function 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); |
| 1079 | 1128 | ||
| 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 | <span | 1134 | <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 | <img | 1148 | <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 rendered | 1793 | // 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 | <img | 2223 | <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"; |
| 4 | 4 | ||
| 5 | const testState = vi.hoisted(() => ({ | 5 | const 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 | })); |
| 9 | 9 | ||
| 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 11 | vi.mock("../assets/assetUrls", () => ({ | 11 | vi.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 | })); |
| 19 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | 19 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| ... | @@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ | ... | @@ -42,6 +42,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ |
| 42 | })); | 42 | })); |
| 43 | 43 | ||
| 44 | import ChatMarkdown from "./ChatMarkdown"; | 44 | import ChatMarkdown from "./ChatMarkdown"; |
| 45 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; | ||
| 45 | 46 | ||
| 46 | const threadRef = { | 47 | const 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 | } |
| 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 | describe("ChatMarkdown workspace images", () => { | 90 | describe("ChatMarkdown workspace images", () => { |
| 62 | beforeEach(() => { | 91 | beforeEach(() => { |
| 63 | testState.resources = []; | 92 | testState.resources = []; |
| 64 | testState.assetState = "success"; | 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 | 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 | }); |
| 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 | testState.assetState = "loading"; | 262 | testState.assetState = "loading"; |
| 113 | 263 | ||
| 114 | const html = render(""); | 264 | const html = render(""); |
| 265 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; | ||
| 115 | 266 | ||
| 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 | }); |
| 119 | 271 | ||
| 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 @@ | ||
| 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,7 +18,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 18 | 18 | ||
| 19 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; | 19 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 20 | import { useAssetUrlState } from "~/assets/assetUrls"; | 20 | import { useAssetUrlState } from "~/assets/assetUrls"; |
| 21 | import ChatMarkdown from "~/components/ChatMarkdown"; | ||
| 22 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; | 21 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | import { useRemoteOpenState } from "~/remoteOpen"; | 22 | import { useRemoteOpenState } from "~/remoteOpen"; |
| 24 | import { useClientSettings } from "~/hooks/useSettings"; | 23 | import { 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"; |
| 42 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 41 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 43 | 42 | ||
| 44 | import FileBrowserPanel from "./FileBrowserPanel"; | 43 | import FileBrowserPanel from "./FileBrowserPanel"; |
| 44 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | ||
| 45 | import { | 45 | import { |
| 46 | type FileCommentAnnotationEntry, | 46 | type FileCommentAnnotationEntry, |
| 47 | type FileCommentAnnotationGroup, | 47 | type FileCommentAnnotationGroup, |
| ... | @@ -727,11 +727,11 @@ function RenderedMarkdownSurface({ | ... | @@ -727,11 +727,11 @@ function RenderedMarkdownSurface({ |
| 727 | 727 | ||
| 728 | return ( | 728 | return ( |
| 729 | <ScrollArea className="min-h-0 flex-1"> | 729 | <ScrollArea className="min-h-0 flex-1"> |
| 730 | <ChatMarkdown | 730 | <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"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 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"; |
| 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 | function renderMarkdownLinkHref(markdown: string): string | undefined { | 27 | function 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 | } |
| 86 | 86 | ||
| 87 | export function isWindowsDrivePathHref(href: string): boolean { | ||
| 88 | return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); | ||
| 89 | } | ||
| 90 | |||
| 87 | function unwrapMarkdownLinkDestination(value: string): string { | 91 | function 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("classifyMarkdownImageSource", () => { | 5 | describe("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 | |||
| 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,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 | } |
| 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 | function normalizeWindowsDrivePath(value: string): string { | 29 | function 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 | } |