authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-22 13:51:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 16:51:59-04:00
log77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade
tree6c1af58206584dd7cc4941cacf9fd167f6dd5ac6
parentdedcd99a9d16240327ce763b885b326aff607bdb
signature Signed by PGP key B5690EEEBB952194

fix: render workspace images in chat markdown (#6433)


15 files changed, 678 insertions(+), 45 deletions(-)

apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+17-1
......@@ -1,4 +1,4 @@
1import { useEffect, useState } from "react";
1import { createContext, useContext, useEffect, useState } from "react";
22import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
33import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
......@@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios
99import type {
1010 MarkdownCodeHighlighter,
1111 MarkdownHighlightedToken,
12 MarkdownImageRenderer,
1213 NativeMarkdownTextStyle,
1314 SelectableMarkdownSkill,
1415} from "./SelectableMarkdownText.types";
1516
17/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
18export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null);
19
1620type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
1721
1822const highlightedCodeCache = new Map<string, HighlightedCode>();
......@@ -379,6 +383,7 @@ function NativeMarkdownImage(props: {
379383 readonly textStyle: NativeMarkdownTextStyle;
380384 readonly onLinkPress?: (href: string) => void;
381385}) {
386 const renderImage = useContext(MarkdownImageRendererContext);
382387 const href = props.node.href;
383388 if (!href) {
384389 return (
......@@ -391,6 +396,17 @@ function NativeMarkdownImage(props: {
391396 );
392397 }
393398
399 if (renderImage) {
400 const rendered = renderImage({
401 href,
402 alt: props.node.alt ?? null,
403 title: props.node.title ?? null,
404 });
405 if (rendered != null) {
406 return <>{rendered}</>;
407 }
408 }
409
394410 return (
395411 <View style={{ gap: 6 }}>
396412 <Image
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+38-33
......@@ -8,7 +8,7 @@ import {
88 nativeMarkdownDocumentRuns,
99 nativeMarkdownWithPreservedSoftBreaks,
1010} from "./nativeMarkdownText";
11import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
1212import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
1313import type {
1414 SelectableMarkdownSkill,
......@@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
2020export type {
2121 MarkdownCodeHighlighter,
2222 MarkdownHighlightedToken,
23 MarkdownImageRenderer,
24 MarkdownImageRequest,
2325 NativeMarkdownTextStyle,
2426 SelectableMarkdownSkill,
2527 SelectableMarkdownTextProps,
......@@ -36,6 +38,7 @@ export function SelectableMarkdownText({
3638 highlightCode,
3739 preserveSoftBreaks = false,
3840 onLinkPress,
41 renderImage,
3942 marginTop = 0,
4043 marginBottom = 0,
4144}: SelectableMarkdownTextProps) {
......@@ -59,38 +62,40 @@ export function SelectableMarkdownText({
5962 }, [markdown, preserveSoftBreaks, skills]);
6063
6164 return (
62 // A percentage width here creates a cyclic intrinsic measurement inside
63 // shrink-to-fit containers such as user-message bubbles. Yoga then gives
64 // the native text node an unbounded second pass and the parent only clips
65 // the resulting single-line width instead of reflowing it.
66 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
67 {chunks.map((chunk, index) => {
68 const content =
69 chunk.kind === "rich" ? (
70 <NativeMarkdownBlock
71 node={chunk.node}
72 skills={skills}
73 textStyle={textStyle}
74 highlightCode={highlightCode}
75 onLinkPress={onLinkPress}
76 />
77 ) : (
78 <NativeMarkdownSelectableText
79 runs={chunk.runs}
80 textStyle={textStyle}
81 onLinkPress={onLinkPress}
82 />
83 );
65 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
66 {/* A percentage width here creates a cyclic intrinsic measurement inside
67 shrink-to-fit containers such as user-message bubbles. Yoga then gives
68 the native text node an unbounded second pass and the parent only clips
69 the resulting single-line width instead of reflowing it. */}
70 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
71 {chunks.map((chunk, index) => {
72 const content =
73 chunk.kind === "rich" ? (
74 <NativeMarkdownBlock
75 node={chunk.node}
76 skills={skills}
77 textStyle={textStyle}
78 highlightCode={highlightCode}
79 onLinkPress={onLinkPress}
80 />
81 ) : (
82 <NativeMarkdownSelectableText
83 runs={chunk.runs}
84 textStyle={textStyle}
85 onLinkPress={onLinkPress}
86 />
87 );
8488
85 return (
86 <View
87 key={chunk.key}
88 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
89 >
90 {content}
91 </View>
92 );
93 })}
94 </View>
89 return (
90 <View
91 key={chunk.key}
92 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
93 >
94 {content}
95 </View>
96 );
97 })}
98 </View>
99 </MarkdownImageRendererContext.Provider>
95100 );
96101}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+2
......@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types
33export type {
44 MarkdownCodeHighlighter,
55 MarkdownHighlightedToken,
6 MarkdownImageRenderer,
7 MarkdownImageRequest,
68 NativeMarkdownTextStyle,
79 SelectableMarkdownSkill,
810 SelectableMarkdownTextProps,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+15
......@@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill {
3636 readonly displayName?: string | null;
3737}
3838
39export interface MarkdownImageRequest {
40 readonly href: string;
41 readonly alt: string | null;
42 readonly title: string | null;
43}
44
45/**
46 * App-supplied renderer for markdown images. The module cannot load
47 * workspace-relative image paths itself — the host app resolves them (for
48 * example through a signed asset URL) and returns the element to show.
49 * Returning null falls back to the module's plain remote-URI rendering.
50 */
51export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode;
52
3953export interface SelectableMarkdownTextProps {
4054 readonly markdown: string;
4155 readonly textStyle: NativeMarkdownTextStyle;
......@@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps {
4357 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
4458 readonly preserveSoftBreaks?: boolean;
4559 readonly onLinkPress?: (href: string) => void;
60 readonly renderImage?: MarkdownImageRenderer;
4661 readonly marginTop?: number;
4762 readonly marginBottom?: number;
4863}
apps/mobile/src/features/threads/ThreadFeed.tsx+138-3
......@@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics";
22import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { type LegendListRef } from "@legendapp/list/react-native";
44import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
56import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
67import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
78import { SymbolView } from "../../components/AppSymbol";
......@@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
5455import {
5556 hasNativeSelectableMarkdownText,
5657 SelectableMarkdownText,
58 type MarkdownImageRenderer,
5759 type NativeMarkdownTextStyle,
5860 type SelectableMarkdownSkill,
5961} from "../../native/SelectableMarkdownText";
......@@ -101,7 +103,7 @@ import {
101103 WORK_GROUP_TOGGLE_HEIGHT,
102104} from "./thread-work-log";
103105import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
104import { useAssetUrl } from "../../state/assets";
106import { useAssetUrl, useAssetUrlState } from "../../state/assets";
105107import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
106108
107109const WIDE_MARKDOWN_BLOCK_OPTIONS = {
......@@ -194,6 +196,98 @@ function MessageAttachmentImage(props: {
194196 );
195197}
196198
199/** Markdown image whose src is a workspace file — loads through a signed asset URL. */
200function ThreadMarkdownImage(props: {
201 readonly environmentId: EnvironmentId;
202 readonly threadId: ThreadId;
203 readonly path: string;
204 readonly alt: string | null;
205 readonly onPressImage: (uri: string) => void;
206}) {
207 const codeBackground = useThemeColor("--color-md-code-bg");
208 const [failedUri, setFailedUri] = useState<string | null>(null);
209 const assetUrl = useAssetUrlState(props.environmentId, {
210 _tag: "workspace-file",
211 threadId: props.threadId,
212 path: props.path,
213 });
214 const uri = assetUrl._tag === "Success" ? assetUrl.url : null;
215 const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri);
216
217 return (
218 <View style={{ gap: 6 }}>
219 {uri === null || failed ? (
220 <View
221 style={{
222 width: "100%",
223 aspectRatio: 16 / 9,
224 borderRadius: 10,
225 backgroundColor: codeBackground,
226 alignItems: "center",
227 justifyContent: "center",
228 }}
229 >
230 {failed ? (
231 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
232 ) : (
233 <ActivityIndicator />
234 )}
235 </View>
236 ) : (
237 <TouchableOpacity
238 accessibilityRole="imagebutton"
239 accessibilityLabel={props.alt ?? "Markdown image"}
240 activeOpacity={0.7}
241 onPress={() => props.onPressImage(uri)}
242 >
243 <Image
244 source={{ uri }}
245 resizeMode="contain"
246 accessible={false}
247 onError={() => setFailedUri(uri)}
248 style={{
249 width: "100%",
250 aspectRatio: 16 / 9,
251 borderRadius: 10,
252 backgroundColor: codeBackground,
253 }}
254 />
255 </TouchableOpacity>
256 )}
257 {props.alt ? (
258 <Text selectable className="text-xs text-foreground-muted">
259 {props.alt}
260 </Text>
261 ) : null}
262 </View>
263 );
264}
265
266function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
267 const codeBackground = useThemeColor("--color-md-code-bg");
268 return (
269 <View style={{ gap: 6 }}>
270 <View
271 style={{
272 width: "100%",
273 aspectRatio: 16 / 9,
274 borderRadius: 10,
275 backgroundColor: codeBackground,
276 alignItems: "center",
277 justifyContent: "center",
278 }}
279 >
280 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
281 </View>
282 {props.alt ? (
283 <Text selectable className="text-xs text-foreground-muted">
284 {props.alt}
285 </Text>
286 ) : null}
287 </View>
288 );
289}
290
197291const MARKDOWN_MONO_FONT = Platform.select({
198292 ios: "ui-monospace",
199293 android: "monospace",
......@@ -409,7 +503,10 @@ function useReviewCommentColors(): ReviewCommentColors {
409503 );
410504}
411505
412function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
506function useMarkdownStyles(
507 onLinkPress: (href: string) => void,
508 renderImage: MarkdownImageRenderer,
509): MarkdownStyleSets {
413510 const { appearance, themeAppearance } = useAppearancePreferences();
414511 const markdownFontSizes = useMemo(
415512 () => resolveMarkdownFontSizes(appearance.baseFontSize),
......@@ -614,6 +711,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
614711 })}
615712 </View>
616713 ),
714 image: ({ node }) =>
715 node.href
716 ? (renderImage({
717 href: node.href,
718 alt: node.alt ?? null,
719 title: node.title ?? null,
720 }) ?? undefined)
721 : undefined,
617722 code_inline: ({ content }) => {
618723 const value = content ?? "";
619724 return (
......@@ -787,6 +892,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
787892 nativeMarkdownTypography,
788893 onLinkPress,
789894 regularFontFamily,
895 renderImage,
790896 themeMode,
791897 userBubbleForegroundMuted,
792898 userBubbleSkillForeground,
......@@ -806,6 +912,7 @@ function renderFeedEntry(
806912 readonly onToggleTurnFold: (turnId: TurnId) => void;
807913 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
808914 readonly onMarkdownLinkPress: (href: string) => void;
915 readonly renderMarkdownImage: MarkdownImageRenderer;
809916 readonly iconSubtleColor: string | import("react-native").ColorValue;
810917 readonly userBubbleColor: string | import("react-native").ColorValue;
811918 readonly markdownStyles: MarkdownStyleSets;
......@@ -904,6 +1011,7 @@ function renderFeedEntry(
9041011 reviewCommentColors={props.reviewCommentColors}
9051012 skills={props.skills}
9061013 onLinkPress={props.onMarkdownLinkPress}
1014 renderImage={props.renderMarkdownImage}
9071015 />
9081016 ) : null}
9091017 {attachments.map((attachment) => {
......@@ -955,6 +1063,7 @@ function renderFeedEntry(
9551063 skills={props.skills}
9561064 textStyle={styles.nativeTextStyle}
9571065 onLinkPress={props.onMarkdownLinkPress}
1066 renderImage={props.renderMarkdownImage}
9581067 />
9591068 ) : (
9601069 <Markdown
......@@ -1040,6 +1149,7 @@ function UserMessageContent(props: {
10401149 readonly reviewCommentColors: ReviewCommentColors;
10411150 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
10421151 readonly onLinkPress: (href: string) => void;
1152 readonly renderImage: MarkdownImageRenderer;
10431153}) {
10441154 const segments = parseReviewCommentMessageSegments(props.text);
10451155 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
......@@ -1052,6 +1162,7 @@ function UserMessageContent(props: {
10521162 textStyle={props.markdownStyles.nativeTextStyle}
10531163 preserveSoftBreaks
10541164 onLinkPress={props.onLinkPress}
1165 renderImage={props.renderImage}
10551166 />
10561167 );
10571168 }
......@@ -1093,6 +1204,7 @@ function UserMessageContent(props: {
10931204 textStyle={props.markdownStyles.nativeTextStyle}
10941205 preserveSoftBreaks
10951206 onLinkPress={props.onLinkPress}
1207 renderImage={props.renderImage}
10961208 />
10971209 ) : (
10981210 <Markdown
......@@ -1414,7 +1526,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14141526 },
14151527 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
14161528 );
1417 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
1529 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
1530 (image) => {
1531 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
1532 if (imageSource._tag === "Direct") {
1533 return null;
1534 }
1535 if (imageSource._tag === "Blocked") {
1536 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
1537 }
1538 return (
1539 <ThreadMarkdownImage
1540 environmentId={props.environmentId}
1541 threadId={props.threadId}
1542 path={imageSource.path}
1543 alt={image.alt}
1544 onPressImage={(uri) => setExpandedImage({ uri })}
1545 />
1546 );
1547 },
1548 [props.environmentId, props.threadId, props.workspaceRoot],
1549 );
1550 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
14181551 const reviewCommentColors = useReviewCommentColors();
14191552 // LegendList does not invalidate visible rows when only the renderItem closure changes.
14201553 // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.
......@@ -1805,6 +1938,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18051938 onToggleTurnFold,
18061939 onPressImage,
18071940 onMarkdownLinkPress,
1941 renderMarkdownImage,
18081942 iconSubtleColor,
18091943 userBubbleColor,
18101944 markdownStyles,
......@@ -1832,6 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18321966 onToggleWorkRow,
18331967 props.environmentId,
18341968 props.skills,
1969 renderMarkdownImage,
18351970 ],
18361971 );
18371972
apps/mobile/src/native/SelectableMarkdownText.ios.tsx+2
......@@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"
88type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
99
1010export type {
11 MarkdownImageRenderer,
12 MarkdownImageRequest,
1113 NativeMarkdownTextStyle,
1214 SelectableMarkdownSkill,
1315} from "@t3tools/mobile-markdown-text/types";
apps/mobile/src/native/SelectableMarkdownText.tsx+2
......@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/
33type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
44
55export type {
6 MarkdownImageRenderer,
7 MarkdownImageRequest,
68 NativeMarkdownTextStyle,
79 SelectableMarkdownSkill,
810} from "@t3tools/mobile-markdown-text/types";
apps/mobile/src/state/assets.ts+21-4
......@@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false))
1212 Atom.withLabel("mobile-asset-url:empty"),
1313);
1414
15export function useAssetUrl(
15export type AssetUrlState =
16 | { readonly _tag: "Loading" }
17 | { readonly _tag: "Failure" }
18 | { readonly _tag: "Success"; readonly url: string };
19
20export function useAssetUrlState(
1621 environmentId: EnvironmentId | null,
1722 resource: AssetResource | null,
18): string | null {
23): AssetUrlState {
1924 const preparedConnection = usePreparedConnection(environmentId);
2025 const result = useAtomValue(
2126 environmentId === null || resource === null
2227 ? EMPTY_ASSET_URL_ATOM
2328 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
2429 );
30 if (result._tag === "Failure") {
31 return { _tag: "Failure" };
32 }
2533 if (preparedConnection._tag === "None" || result._tag !== "Success") {
26 return null;
34 return { _tag: "Loading" };
2735 }
28 return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
36 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
37 return url === null ? { _tag: "Failure" } : { _tag: "Success", url };
38}
39
40export function useAssetUrl(
41 environmentId: EnvironmentId | null,
42 resource: AssetResource | null,
43): string | null {
44 const state = useAssetUrlState(environmentId, resource);
45 return state._tag === "Success" ? state.url : null;
2946}
apps/web/src/components/ChatMarkdown.tsx+116-3
......@@ -19,6 +19,7 @@ import {
1919 squashAtomCommandFailure,
2020 type AtomCommandResult,
2121} from "@t3tools/client-runtime/state/runtime";
22import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
2223import * as Cause from "effect/Cause";
2324import { AsyncResult } from "effect/unstable/reactivity";
2425import React, {
......@@ -83,6 +84,7 @@ import {
8384 type MarkdownFileLinkMeta,
8485} from "../markdown-links";
8586import { readLocalApi } from "../localApi";
87import { useAssetUrlState } from "../assets/assetUrls";
8688import { cn } from "../lib/utils";
8789import { useRightPanelStore } from "../rightPanelStore";
8890import { useActiveEnvironmentId } from "../state/entities";
......@@ -179,6 +181,36 @@ export function orderedListGutterStyle(
179181 return { "--list-gutter": `${markerWidth + 1}ch` };
180182}
181183
184type MarkdownHtmlAstNode = {
185 type?: string;
186 tagName?: string;
187 properties?: Record<string, unknown>;
188 children?: MarkdownHtmlAstNode[];
189};
190
191/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */
192function rehypeNormalizeWindowsImageSrc() {
193 return (tree: MarkdownHtmlAstNode) => {
194 const visit = (node: MarkdownHtmlAstNode) => {
195 const src = node.properties?.src;
196 if (
197 node.type === "element" &&
198 node.tagName === "img" &&
199 typeof src === "string" &&
200 /^[A-Za-z]:[\\/]/.test(src)
201 ) {
202 node.properties = {
203 ...node.properties,
204 src: `file:///${src.replaceAll("\\", "/")}`,
205 };
206 }
207 node.children?.forEach(visit);
208 };
209
210 visit(tree);
211 };
212}
213
182214const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
183215 ...defaultSchema,
184216 attributes: {
......@@ -190,6 +222,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
190222 protocols: {
191223 ...defaultSchema.protocols,
192224 href: [...(defaultSchema.protocols?.href ?? []), "file"],
225 src: [...(defaultSchema.protocols?.src ?? []), "file"],
193226 },
194227} satisfies Parameters<typeof rehypeSanitize>[0];
195228
......@@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
212245
213246const CHAT_MARKDOWN_REHYPE_PLUGINS = [
214247 rehypeRaw,
248 rehypeNormalizeWindowsImageSrc,
215249 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
216250] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
217251
......@@ -943,6 +977,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
943977 );
944978});
945979
980const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME =
981 "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain";
982
983// block! outranks the unlayered `.chat-markdown img { display: inline-block }`
984// rule, keeping workspace images on the same block layout as their placeholder.
985const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
986 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
987 "my-1 block! rounded-lg border border-border/40",
988);
989
990function ChatMarkdownImageFallback(props: { readonly alt: string }) {
991 return (
992 <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">
993 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
994 {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"}
995 </span>
996 );
997}
998
999/** Markdown images whose src is a workspace file path load through a signed asset URL. */
1000const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: {
1001 readonly threadRef: ScopedThreadRef;
1002 readonly path: string;
1003 readonly alt: string;
1004}) {
1005 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {
1006 _tag: "workspace-file",
1007 threadId: props.threadRef.threadId,
1008 path: props.path,
1009 });
1010 const [failedUrl, setFailedUrl] = useState<string | null>(null);
1011
1012 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
1013 return <ChatMarkdownImageFallback alt={props.alt} />;
1014 }
1015 if (assetUrl._tag !== "Success") {
1016 return (
1017 <span
1018 role="status"
1019 aria-label="Loading image"
1020 className="my-1 block aspect-video w-full max-w-[30rem] rounded-lg bg-muted/60"
1021 />
1022 );
1023 }
1024 return (
1025 <img
1026 src={assetUrl.url}
1027 alt={props.alt}
1028 loading="lazy"
1029 draggable={false}
1030 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
1031 onError={() => setFailedUrl(assetUrl.url)}
1032 />
1033 );
1034});
1035
9461036function leadingExternalLinkTextLength(text: string): number {
9471037 const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0];
9481038 if (protocol) return protocol.length;
......@@ -1712,9 +1802,6 @@ function ChatMarkdown({
17121802 props.className,
17131803 );
17141804 },
1715 img({ node: _node, title: _title, ...props }) {
1716 return <img {...props} />;
1717 },
17181805 code({ node, children, className, ...props }) {
17191806 if (node?.properties?.dataInlineCode != null) {
17201807 const codeText = nodeToPlainText(children);
......@@ -1731,6 +1818,32 @@ function ChatMarkdown({
17311818 </code>
17321819 );
17331820 },
1821 img({ node: _node, title: _title, src, alt, ...props }) {
1822 const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : "";
1823 const altText = alt ?? "";
1824 const imageSource = classifyMarkdownImageSource(srcString, cwd);
1825 if (imageSource._tag === "Direct") {
1826 return (
1827 <img
1828 {...props}
1829 src={imageSource.uri}
1830 alt={altText}
1831 loading="lazy"
1832 className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)}
1833 />
1834 );
1835 }
1836 if (imageSource._tag === "WorkspaceFile" && threadRef) {
1837 return (
1838 <ChatMarkdownWorkspaceImage
1839 threadRef={threadRef}
1840 path={imageSource.path}
1841 alt={altText}
1842 />
1843 );
1844 }
1845 return <ChatMarkdownImageFallback alt={altText} />;
1846 },
17341847 table({ node: _node, ...props }) {
17351848 return <MarkdownTable {...props} />;
17361849 },
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx created+135
......@@ -0,0 +1,135 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const testState = vi.hoisted(() => ({
6 resources: [] as Array<unknown>,
7 assetState: "success" as "success" | "loading",
8}));
9
10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
11vi.mock("../assets/assetUrls", () => ({
12 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
13 testState.resources.push(resource);
14 return testState.assetState === "loading"
15 ? { _tag: "Loading" }
16 : { _tag: "Success", url: "https://signed.test/workspace-image.svg" };
17 },
18}));
19vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
20vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
21vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
22vi.mock("../state/session", async (importOriginal) => ({
23 ...(await importOriginal<typeof import("../state/session")>()),
24 usePreparedConnection: () => ({ _tag: "Loading" }),
25}));
26vi.mock("../state/entities", () => ({
27 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
28}));
29vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));
30vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
31
32import ChatMarkdown from "./ChatMarkdown";
33
34const threadRef = {
35 environmentId: EnvironmentId.make("env-windows"),
36 threadId: ThreadId.make("thread-windows"),
37};
38
39function render(markdown: string): string {
40 return renderToStaticMarkup(
41 <ChatMarkdown cwd={"C:\\Users\\shawn\\project"} threadRef={threadRef} text={markdown} />,
42 );
43}
44
45function renderWithoutThread(markdown: string): string {
46 return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />);
47}
48
49describe("ChatMarkdown workspace images", () => {
50 beforeEach(() => {
51 testState.resources = [];
52 testState.assetState = "success";
53 });
54
55 it("loads every Windows workspace path form through a signed asset URL", () => {
56 const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";
57 const html = render(
58 [
59 "![relative](.t3/workspace-image.svg)",
60 `![absolute](${imagePath})`,
61 `![file URL](file:///${imagePath})`,
62 "![UNC file URL](file://server/share/workspace-image.svg)",
63 ].join("\n\n"),
64 );
65
66 expect(testState.resources).toEqual([
67 {
68 _tag: "workspace-file",
69 threadId: threadRef.threadId,
70 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
71 },
72 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
73 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
74 {
75 _tag: "workspace-file",
76 threadId: threadRef.threadId,
77 path: "\\\\server\\share\\workspace-image.svg",
78 },
79 ]);
80 expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4);
81 expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4);
82 expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4);
83 expect(html).not.toContain("Image unavailable");
84 });
85
86 it("normalizes a drive-absolute src in raw image HTML", () => {
87 const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`);
88
89 expect(testState.resources).toEqual([
90 {
91 _tag: "workspace-file",
92 threadId: threadRef.threadId,
93 path: "D:/screens/workspace-image.svg",
94 },
95 ]);
96 expect(html).toContain("https://signed.test/workspace-image.svg");
97 });
98
99 it("uses a static placeholder while a signed asset URL loads", () => {
100 testState.assetState = "loading";
101
102 const html = render("![loading](.t3/workspace-image.svg)");
103
104 expect(html).toContain('aria-label="Loading image"');
105 expect(html).not.toContain("animate-pulse");
106 });
107
108 it("never passes a workspace source to a raw image when thread context is unavailable", () => {
109 const html = renderWithoutThread(
110 "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)",
111 );
112
113 expect(testState.resources).toEqual([]);
114 expect(html).toContain("Image unavailable");
115 expect(html).not.toContain("file://");
116 });
117
118 it("blocks unsupported image schemes instead of passing them to a raw image", () => {
119 const html = render("![unsupported](content://media/image/1)");
120
121 expect(testState.resources).toEqual([]);
122 expect(html).toContain("Image unavailable");
123 expect(html).not.toContain("content://");
124 });
125
126 it("keeps remote images directly loadable", () => {
127 const html = render("![remote](https://example.com/image.png)");
128
129 expect(testState.resources).toEqual([]);
130 expect(html).toContain('src="https://example.com/image.png"');
131 expect(html).toContain("max-w-[min(100%,30rem)]");
132 expect(html).toContain("max-h-[30rem]");
133 expect(html).not.toContain("Image unavailable");
134 });
135});
apps/web/src/markdown-links.test.ts+24
......@@ -90,6 +90,18 @@ describe("rewriteMarkdownFileUriHref", () => {
9090 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69");
9191 });
9292
93 it("preserves file uri authorities as windows UNC paths", () => {
94 expect(rewriteMarkdownFileUriHref("file://server/share/workspace-image.svg")).toBe(
95 "\\\\server\\share\\workspace-image.svg",
96 );
97 });
98
99 it("treats a localhost file uri as a local path", () => {
100 expect(rewriteMarkdownFileUriHref("file://localhost/home/me/notes.md")).toBe(
101 "/home/me/notes.md",
102 );
103 });
104
93105 it("unwraps angle-bracketed file uri hrefs", () => {
94106 expect(
95107 rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "),
......@@ -138,6 +150,18 @@ describe("resolveMarkdownFileLinkTarget", () => {
138150 );
139151 });
140152
153 it("resolves file uri authorities as windows UNC paths", () => {
154 expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe(
155 "\\\\server\\share\\workspace-image.svg",
156 );
157 });
158
159 it("resolves a localhost file uri as a local path", () => {
160 expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe(
161 "/home/me/notes.md",
162 );
163 });
164
141165 it("formats tooltip display paths relative to the cwd when possible", () => {
142166 expect(
143167 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+4-1
......@@ -108,7 +108,10 @@ function parseFileUrlHref(
108108 const parsed = new URL(href);
109109 if (parsed.protocol.toLowerCase() !== "file:") return null;
110110
111 const rawPath = parsed.pathname;
111 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
112 const rawPath = uncHostname
113 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
114 : parsed.pathname;
112115 if (rawPath.length === 0) return null;
113116
114117 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
packages/client-runtime/package.json+4
......@@ -15,6 +15,10 @@
1515 "types": "./src/environment/index.ts",
1616 "default": "./src/environment/index.ts"
1717 },
18 "./markdown-images": {
19 "types": "./src/markdownImages.ts",
20 "default": "./src/markdownImages.ts"
21 },
1822 "./errors": {
1923 "types": "./src/errors/index.ts",
2024 "default": "./src/errors/index.ts"
packages/client-runtime/src/markdownImages.test.ts created+62
......@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { classifyMarkdownImageSource } from "./markdownImages.js";
4
5describe("classifyMarkdownImageSource", () => {
6 it.each([
7 "https://example.com/image.png",
8 "HTTP://example.com/image.png",
9 "data:image/png;base64,AAAA",
10 "blob:https://app.t3.codes/image-id",
11 "//cdn.example.com/image.png",
12 ])("keeps %s directly loadable", (uri) => {
13 expect(classifyMarkdownImageSource(uri, "/workspace/project")).toEqual({
14 _tag: "Direct",
15 uri,
16 });
17 });
18
19 it.each([
20 ["images/result.png", "/workspace/project", "/workspace/project/images/result.png"],
21 ["./images/result.png", "/workspace/project", "/workspace/project/./images/result.png"],
22 [
23 "images/result.png",
24 "C:\\Users\\dara\\project",
25 "C:\\Users\\dara\\project\\images\\result.png",
26 ],
27 [
28 "images\\result.png",
29 "C:\\Users\\dara\\project",
30 "C:\\Users\\dara\\project\\images\\result.png",
31 ],
32 ["/workspace/project/image.png", null, "/workspace/project/image.png"],
33 ["/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"],
34 ["C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"],
35 ["\\\\server\\share\\image.png", null, "\\\\server\\share\\image.png"],
36 ["file:///workspace/project/image%20one.png", null, "/workspace/project/image one.png"],
37 ["file:///C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"],
38 ["file://localhost/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"],
39 ["file://server/share/image.png", null, "\\\\server\\share\\image.png"],
40 ])("maps %s to a workspace file", (source, workspaceRoot, path) => {
41 expect(classifyMarkdownImageSource(source, workspaceRoot)).toEqual({
42 _tag: "WorkspaceFile",
43 path,
44 });
45 });
46
47 it.each([
48 null,
49 "",
50 "#image",
51 "?image=1",
52 "image.png",
53 "~/image.png",
54 "javascript:alert(1)",
55 "ftp://example.com/image.png",
56 "content://media/image/1",
57 "custom:image.png",
58 "file://%",
59 ])("blocks unsupported or unresolved source %s", (source) => {
60 expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" });
61 });
62});
packages/client-runtime/src/markdownImages.ts created+98
......@@ -0,0 +1,98 @@
1const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;
2const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4
5export type MarkdownImageSource =
6 | { readonly _tag: "Direct"; readonly uri: string }
7 | { readonly _tag: "WorkspaceFile"; readonly path: string }
8 | { readonly _tag: "Blocked" };
9
10function safeDecode(value: string): string {
11 try {
12 return decodeURIComponent(value);
13 } catch {
14 return value;
15 }
16}
17
18function normalizeSource(value: string): string {
19 const trimmed = value.trim();
20 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
21}
22
23function normalizeWindowsDrivePath(value: string): string {
24 return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value;
25}
26
27function parseFileUrl(value: string): string | null {
28 try {
29 const parsed = new URL(value);
30 if (parsed.protocol.toLowerCase() !== "file:") return null;
31
32 if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") {
33 const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\");
34 return `\\\\${safeDecode(parsed.hostname)}${pathname}`;
35 }
36
37 const pathname = safeDecode(parsed.pathname);
38 if (pathname.length === 0) return null;
39 return normalizeWindowsDrivePath(pathname);
40 } catch {
41 return null;
42 }
43}
44
45function stripSearchAndHash(value: string): string {
46 const searchIndex = value.indexOf("?");
47 const hashIndex = value.indexOf("#");
48 const end = [searchIndex, hashIndex]
49 .filter((index) => index >= 0)
50 .reduce((lowest, index) => Math.min(lowest, index), value.length);
51 return value.slice(0, end);
52}
53
54function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
55 const windows =
56 WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\");
57 const separator = windows ? "\\" : "/";
58 const root = workspaceRoot.replace(/[\\/]+$/, "");
59 const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, "");
60 return `${root}${separator}${path}`;
61}
62
63/**
64 * Classifies a markdown image source by where its bytes must be loaded from.
65 * Filesystem paths belong to the environment host and must never reach a
66 * browser or native image component without first becoming a signed asset URL.
67 */
68export function classifyMarkdownImageSource(
69 value: string | null | undefined,
70 workspaceRoot?: string | null,
71): MarkdownImageSource {
72 if (value === null || value === undefined) return { _tag: "Blocked" };
73
74 const source = normalizeSource(value);
75 if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) {
76 return { _tag: "Blocked" };
77 }
78 if (DIRECT_IMAGE_SOURCE_PATTERN.test(source)) {
79 return { _tag: "Direct", uri: source };
80 }
81
82 if (/^file:/i.test(source)) {
83 const path = parseFileUrl(source);
84 return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path };
85 }
86
87 const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source)));
88 if (path.length === 0) return { _tag: "Blocked" };
89 if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) {
90 return { _tag: "WorkspaceFile", path };
91 }
92 if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) {
93 return { _tag: "Blocked" };
94 }
95 if (!workspaceRoot) return { _tag: "Blocked" };
96
97 return { _tag: "WorkspaceFile", path: joinWorkspacePath(workspaceRoot, path) };
98}