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 @@...@@ -1,4 +1,4 @@
1import { useEffect, useState } from "react";1import { createContext, useContext, useEffect, useState } from "react";
2import { Image, ScrollView, Text, useColorScheme, View } from "react-native";2import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
3import type { MarkdownNode } from "react-native-nitro-markdown/headless";3import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
...@@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios...@@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios
9import type {9import type {
10 MarkdownCodeHighlighter,10 MarkdownCodeHighlighter,
11 MarkdownHighlightedToken,11 MarkdownHighlightedToken,
12 MarkdownImageRenderer,
12 NativeMarkdownTextStyle,13 NativeMarkdownTextStyle,
13 SelectableMarkdownSkill,14 SelectableMarkdownSkill,
14} from "./SelectableMarkdownText.types";15} 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
16type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;20type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
1721
18const highlightedCodeCache = new Map<string, HighlightedCode>();22const highlightedCodeCache = new Map<string, HighlightedCode>();
...@@ -379,6 +383,7 @@ function NativeMarkdownImage(props: {...@@ -379,6 +383,7 @@ function NativeMarkdownImage(props: {
379 readonly textStyle: NativeMarkdownTextStyle;383 readonly textStyle: NativeMarkdownTextStyle;
380 readonly onLinkPress?: (href: string) => void;384 readonly onLinkPress?: (href: string) => void;
381}) {385}) {
386 const renderImage = useContext(MarkdownImageRendererContext);
382 const href = props.node.href;387 const href = props.node.href;
383 if (!href) {388 if (!href) {
384 return (389 return (
...@@ -391,6 +396,17 @@ function NativeMarkdownImage(props: {...@@ -391,6 +396,17 @@ function NativeMarkdownImage(props: {
391 );396 );
392 }397 }
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
394 return (410 return (
395 <View style={{ gap: 6 }}>411 <View style={{ gap: 6 }}>
396 <Image412 <Image
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+38-33
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 nativeMarkdownDocumentRuns,8 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithPreservedSoftBreaks,9 nativeMarkdownWithPreservedSoftBreaks,
10} from "./nativeMarkdownText";10} from "./nativeMarkdownText";
11import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
13import type {13import type {
14 SelectableMarkdownSkill,14 SelectableMarkdownSkill,
...@@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];...@@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
20export type {20export type {
21 MarkdownCodeHighlighter,21 MarkdownCodeHighlighter,
22 MarkdownHighlightedToken,22 MarkdownHighlightedToken,
23 MarkdownImageRenderer,
24 MarkdownImageRequest,
23 NativeMarkdownTextStyle,25 NativeMarkdownTextStyle,
24 SelectableMarkdownSkill,26 SelectableMarkdownSkill,
25 SelectableMarkdownTextProps,27 SelectableMarkdownTextProps,
...@@ -36,6 +38,7 @@ export function SelectableMarkdownText({...@@ -36,6 +38,7 @@ export function SelectableMarkdownText({
36 highlightCode,38 highlightCode,
37 preserveSoftBreaks = false,39 preserveSoftBreaks = false,
38 onLinkPress,40 onLinkPress,
41 renderImage,
39 marginTop = 0,42 marginTop = 0,
40 marginBottom = 0,43 marginBottom = 0,
41}: SelectableMarkdownTextProps) {44}: SelectableMarkdownTextProps) {
...@@ -59,38 +62,40 @@ export function SelectableMarkdownText({...@@ -59,38 +62,40 @@ export function SelectableMarkdownText({
59 }, [markdown, preserveSoftBreaks, skills]);62 }, [markdown, preserveSoftBreaks, skills]);
6063
61 return (64 return (
62 // A percentage width here creates a cyclic intrinsic measurement inside65 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
63 // shrink-to-fit containers such as user-message bubbles. Yoga then gives66 {/* A percentage width here creates a cyclic intrinsic measurement inside
64 // the native text node an unbounded second pass and the parent only clips67 shrink-to-fit containers such as user-message bubbles. Yoga then gives
65 // the resulting single-line width instead of reflowing it.68 the native text node an unbounded second pass and the parent only clips
66 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>69 the resulting single-line width instead of reflowing it. */}
67 {chunks.map((chunk, index) => {70 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
68 const content =71 {chunks.map((chunk, index) => {
69 chunk.kind === "rich" ? (72 const content =
70 <NativeMarkdownBlock73 chunk.kind === "rich" ? (
71 node={chunk.node}74 <NativeMarkdownBlock
72 skills={skills}75 node={chunk.node}
73 textStyle={textStyle}76 skills={skills}
74 highlightCode={highlightCode}77 textStyle={textStyle}
75 onLinkPress={onLinkPress}78 highlightCode={highlightCode}
76 />79 onLinkPress={onLinkPress}
77 ) : (80 />
78 <NativeMarkdownSelectableText81 ) : (
79 runs={chunk.runs}82 <NativeMarkdownSelectableText
80 textStyle={textStyle}83 runs={chunk.runs}
81 onLinkPress={onLinkPress}84 textStyle={textStyle}
82 />85 onLinkPress={onLinkPress}
83 );86 />
87 );
8488
85 return (89 return (
86 <View90 <View
87 key={chunk.key}91 key={chunk.key}
88 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}92 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
89 >93 >
90 {content}94 {content}
91 </View>95 </View>
92 );96 );
93 })}97 })}
94 </View>98 </View>
99 </MarkdownImageRendererContext.Provider>
95 );100 );
96}101}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+2
...@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types...@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types
3export type {3export type {
4 MarkdownCodeHighlighter,4 MarkdownCodeHighlighter,
5 MarkdownHighlightedToken,5 MarkdownHighlightedToken,
6 MarkdownImageRenderer,
7 MarkdownImageRequest,
6 NativeMarkdownTextStyle,8 NativeMarkdownTextStyle,
7 SelectableMarkdownSkill,9 SelectableMarkdownSkill,
8 SelectableMarkdownTextProps,10 SelectableMarkdownTextProps,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+15
...@@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill {...@@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill {
36 readonly displayName?: string | null;36 readonly displayName?: string | null;
37}37}
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
39export interface SelectableMarkdownTextProps {53export interface SelectableMarkdownTextProps {
40 readonly markdown: string;54 readonly markdown: string;
41 readonly textStyle: NativeMarkdownTextStyle;55 readonly textStyle: NativeMarkdownTextStyle;
...@@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps {...@@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps {
43 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;57 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
44 readonly preserveSoftBreaks?: boolean;58 readonly preserveSoftBreaks?: boolean;
45 readonly onLinkPress?: (href: string) => void;59 readonly onLinkPress?: (href: string) => void;
60 readonly renderImage?: MarkdownImageRenderer;
46 readonly marginTop?: number;61 readonly marginTop?: number;
47 readonly marginBottom?: number;62 readonly marginBottom?: number;
48}63}
apps/mobile/src/features/threads/ThreadFeed.tsx+138-3
...@@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics";...@@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";6import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";7import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
7import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
...@@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";...@@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
54import {55import {
55 hasNativeSelectableMarkdownText,56 hasNativeSelectableMarkdownText,
56 SelectableMarkdownText,57 SelectableMarkdownText,
58 type MarkdownImageRenderer,
57 type NativeMarkdownTextStyle,59 type NativeMarkdownTextStyle,
58 type SelectableMarkdownSkill,60 type SelectableMarkdownSkill,
59} from "../../native/SelectableMarkdownText";61} from "../../native/SelectableMarkdownText";
...@@ -101,7 +103,7 @@ import {...@@ -101,7 +103,7 @@ import {
101 WORK_GROUP_TOGGLE_HEIGHT,103 WORK_GROUP_TOGGLE_HEIGHT,
102} from "./thread-work-log";104} from "./thread-work-log";
103import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";105import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
104import { useAssetUrl } from "../../state/assets";106import { useAssetUrl, useAssetUrlState } from "../../state/assets";
105import { resolveWorkspaceRelativeFilePath } from "../files/filePath";107import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
106108
107const WIDE_MARKDOWN_BLOCK_OPTIONS = {109const WIDE_MARKDOWN_BLOCK_OPTIONS = {
...@@ -194,6 +196,98 @@ function MessageAttachmentImage(props: {...@@ -194,6 +196,98 @@ function MessageAttachmentImage(props: {
194 );196 );
195}197}
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
197const MARKDOWN_MONO_FONT = Platform.select({291const MARKDOWN_MONO_FONT = Platform.select({
198 ios: "ui-monospace",292 ios: "ui-monospace",
199 android: "monospace",293 android: "monospace",
...@@ -409,7 +503,10 @@ function useReviewCommentColors(): ReviewCommentColors {...@@ -409,7 +503,10 @@ function useReviewCommentColors(): ReviewCommentColors {
409 );503 );
410}504}
411505
412function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {506function useMarkdownStyles(
507 onLinkPress: (href: string) => void,
508 renderImage: MarkdownImageRenderer,
509): MarkdownStyleSets {
413 const { appearance, themeAppearance } = useAppearancePreferences();510 const { appearance, themeAppearance } = useAppearancePreferences();
414 const markdownFontSizes = useMemo(511 const markdownFontSizes = useMemo(
415 () => resolveMarkdownFontSizes(appearance.baseFontSize),512 () => resolveMarkdownFontSizes(appearance.baseFontSize),
...@@ -614,6 +711,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -614,6 +711,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
614 })}711 })}
615 </View>712 </View>
616 ),713 ),
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,
617 code_inline: ({ content }) => {722 code_inline: ({ content }) => {
618 const value = content ?? "";723 const value = content ?? "";
619 return (724 return (
...@@ -787,6 +892,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -787,6 +892,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
787 nativeMarkdownTypography,892 nativeMarkdownTypography,
788 onLinkPress,893 onLinkPress,
789 regularFontFamily,894 regularFontFamily,
895 renderImage,
790 themeMode,896 themeMode,
791 userBubbleForegroundMuted,897 userBubbleForegroundMuted,
792 userBubbleSkillForeground,898 userBubbleSkillForeground,
...@@ -806,6 +912,7 @@ function renderFeedEntry(...@@ -806,6 +912,7 @@ function renderFeedEntry(
806 readonly onToggleTurnFold: (turnId: TurnId) => void;912 readonly onToggleTurnFold: (turnId: TurnId) => void;
807 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;913 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
808 readonly onMarkdownLinkPress: (href: string) => void;914 readonly onMarkdownLinkPress: (href: string) => void;
915 readonly renderMarkdownImage: MarkdownImageRenderer;
809 readonly iconSubtleColor: string | import("react-native").ColorValue;916 readonly iconSubtleColor: string | import("react-native").ColorValue;
810 readonly userBubbleColor: string | import("react-native").ColorValue;917 readonly userBubbleColor: string | import("react-native").ColorValue;
811 readonly markdownStyles: MarkdownStyleSets;918 readonly markdownStyles: MarkdownStyleSets;
...@@ -904,6 +1011,7 @@ function renderFeedEntry(...@@ -904,6 +1011,7 @@ function renderFeedEntry(
904 reviewCommentColors={props.reviewCommentColors}1011 reviewCommentColors={props.reviewCommentColors}
905 skills={props.skills}1012 skills={props.skills}
906 onLinkPress={props.onMarkdownLinkPress}1013 onLinkPress={props.onMarkdownLinkPress}
1014 renderImage={props.renderMarkdownImage}
907 />1015 />
908 ) : null}1016 ) : null}
909 {attachments.map((attachment) => {1017 {attachments.map((attachment) => {
...@@ -955,6 +1063,7 @@ function renderFeedEntry(...@@ -955,6 +1063,7 @@ function renderFeedEntry(
955 skills={props.skills}1063 skills={props.skills}
956 textStyle={styles.nativeTextStyle}1064 textStyle={styles.nativeTextStyle}
957 onLinkPress={props.onMarkdownLinkPress}1065 onLinkPress={props.onMarkdownLinkPress}
1066 renderImage={props.renderMarkdownImage}
958 />1067 />
959 ) : (1068 ) : (
960 <Markdown1069 <Markdown
...@@ -1040,6 +1149,7 @@ function UserMessageContent(props: {...@@ -1040,6 +1149,7 @@ function UserMessageContent(props: {
1040 readonly reviewCommentColors: ReviewCommentColors;1149 readonly reviewCommentColors: ReviewCommentColors;
1041 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;1150 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
1042 readonly onLinkPress: (href: string) => void;1151 readonly onLinkPress: (href: string) => void;
1152 readonly renderImage: MarkdownImageRenderer;
1043}) {1153}) {
1044 const segments = parseReviewCommentMessageSegments(props.text);1154 const segments = parseReviewCommentMessageSegments(props.text);
1045 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");1155 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
...@@ -1052,6 +1162,7 @@ function UserMessageContent(props: {...@@ -1052,6 +1162,7 @@ function UserMessageContent(props: {
1052 textStyle={props.markdownStyles.nativeTextStyle}1162 textStyle={props.markdownStyles.nativeTextStyle}
1053 preserveSoftBreaks1163 preserveSoftBreaks
1054 onLinkPress={props.onLinkPress}1164 onLinkPress={props.onLinkPress}
1165 renderImage={props.renderImage}
1055 />1166 />
1056 );1167 );
1057 }1168 }
...@@ -1093,6 +1204,7 @@ function UserMessageContent(props: {...@@ -1093,6 +1204,7 @@ function UserMessageContent(props: {
1093 textStyle={props.markdownStyles.nativeTextStyle}1204 textStyle={props.markdownStyles.nativeTextStyle}
1094 preserveSoftBreaks1205 preserveSoftBreaks
1095 onLinkPress={props.onLinkPress}1206 onLinkPress={props.onLinkPress}
1207 renderImage={props.renderImage}
1096 />1208 />
1097 ) : (1209 ) : (
1098 <Markdown1210 <Markdown
...@@ -1414,7 +1526,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1414,7 +1526,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1414 },1526 },
1415 [props.environmentId, props.threadId, props.workspaceRoot, navigation],1527 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
1416 );1528 );
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);
1418 const reviewCommentColors = useReviewCommentColors();1551 const reviewCommentColors = useReviewCommentColors();
1419 // LegendList does not invalidate visible rows when only the renderItem closure changes.1552 // LegendList does not invalidate visible rows when only the renderItem closure changes.
1420 // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.1553 // 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) {...@@ -1805,6 +1938,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1805 onToggleTurnFold,1938 onToggleTurnFold,
1806 onPressImage,1939 onPressImage,
1807 onMarkdownLinkPress,1940 onMarkdownLinkPress,
1941 renderMarkdownImage,
1808 iconSubtleColor,1942 iconSubtleColor,
1809 userBubbleColor,1943 userBubbleColor,
1810 markdownStyles,1944 markdownStyles,
...@@ -1832,6 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1832,6 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1832 onToggleWorkRow,1966 onToggleWorkRow,
1833 props.environmentId,1967 props.environmentId,
1834 props.skills,1968 props.skills,
1969 renderMarkdownImage,
1835 ],1970 ],
1836 );1971 );
18371972
apps/mobile/src/native/SelectableMarkdownText.ios.tsx+2
...@@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"...@@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
99
10export type {10export type {
11 MarkdownImageRenderer,
12 MarkdownImageRequest,
11 NativeMarkdownTextStyle,13 NativeMarkdownTextStyle,
12 SelectableMarkdownSkill,14 SelectableMarkdownSkill,
13} from "@t3tools/mobile-markdown-text/types";15} 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/...@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/
3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
44
5export type {5export type {
6 MarkdownImageRenderer,
7 MarkdownImageRequest,
6 NativeMarkdownTextStyle,8 NativeMarkdownTextStyle,
7 SelectableMarkdownSkill,9 SelectableMarkdownSkill,
8} from "@t3tools/mobile-markdown-text/types";10} 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))...@@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false))
12 Atom.withLabel("mobile-asset-url:empty"),12 Atom.withLabel("mobile-asset-url:empty"),
13);13);
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(
16 environmentId: EnvironmentId | null,21 environmentId: EnvironmentId | null,
17 resource: AssetResource | null,22 resource: AssetResource | null,
18): string | null {23): AssetUrlState {
19 const preparedConnection = usePreparedConnection(environmentId);24 const preparedConnection = usePreparedConnection(environmentId);
20 const result = useAtomValue(25 const result = useAtomValue(
21 environmentId === null || resource === null26 environmentId === null || resource === null
22 ? EMPTY_ASSET_URL_ATOM27 ? EMPTY_ASSET_URL_ATOM
23 : assetEnvironment.createUrl({ environmentId, input: { resource } }),28 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
24 );29 );
30 if (result._tag === "Failure") {
31 return { _tag: "Failure" };
32 }
25 if (preparedConnection._tag === "None" || result._tag !== "Success") {33 if (preparedConnection._tag === "None" || result._tag !== "Success") {
26 return null;34 return { _tag: "Loading" };
27 }35 }
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;
29}46}
apps/web/src/components/ChatMarkdown.tsx+116-3
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 squashAtomCommandFailure,19 squashAtomCommandFailure,
20 type AtomCommandResult,20 type AtomCommandResult,
21} from "@t3tools/client-runtime/state/runtime";21} from "@t3tools/client-runtime/state/runtime";
22import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
22import * as Cause from "effect/Cause";23import * as Cause from "effect/Cause";
23import { AsyncResult } from "effect/unstable/reactivity";24import { AsyncResult } from "effect/unstable/reactivity";
24import React, {25import React, {
...@@ -83,6 +84,7 @@ import {...@@ -83,6 +84,7 @@ import {
83 type MarkdownFileLinkMeta,84 type MarkdownFileLinkMeta,
84} from "../markdown-links";85} from "../markdown-links";
85import { readLocalApi } from "../localApi";86import { readLocalApi } from "../localApi";
87import { useAssetUrlState } from "../assets/assetUrls";
86import { cn } from "../lib/utils";88import { cn } from "../lib/utils";
87import { useRightPanelStore } from "../rightPanelStore";89import { useRightPanelStore } from "../rightPanelStore";
88import { useActiveEnvironmentId } from "../state/entities";90import { useActiveEnvironmentId } from "../state/entities";
...@@ -179,6 +181,36 @@ export function orderedListGutterStyle(...@@ -179,6 +181,36 @@ export function orderedListGutterStyle(
179 return { "--list-gutter": `${markerWidth + 1}ch` };181 return { "--list-gutter": `${markerWidth + 1}ch` };
180}182}
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
182const CHAT_MARKDOWN_SANITIZE_SCHEMA = {214const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
183 ...defaultSchema,215 ...defaultSchema,
184 attributes: {216 attributes: {
...@@ -190,6 +222,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -190,6 +222,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
190 protocols: {222 protocols: {
191 ...defaultSchema.protocols,223 ...defaultSchema.protocols,
192 href: [...(defaultSchema.protocols?.href ?? []), "file"],224 href: [...(defaultSchema.protocols?.href ?? []), "file"],
225 src: [...(defaultSchema.protocols?.src ?? []), "file"],
193 },226 },
194} satisfies Parameters<typeof rehypeSanitize>[0];227} satisfies Parameters<typeof rehypeSanitize>[0];
195228
...@@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [...@@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
212245
213const CHAT_MARKDOWN_REHYPE_PLUGINS = [246const CHAT_MARKDOWN_REHYPE_PLUGINS = [
214 rehypeRaw,247 rehypeRaw,
248 rehypeNormalizeWindowsImageSrc,
215 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],249 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
216] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;250] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
217251
...@@ -943,6 +977,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:...@@ -943,6 +977,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
943 );977 );
944});978});
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
946function leadingExternalLinkTextLength(text: string): number {1036function leadingExternalLinkTextLength(text: string): number {
947 const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0];1037 const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0];
948 if (protocol) return protocol.length;1038 if (protocol) return protocol.length;
...@@ -1712,9 +1802,6 @@ function ChatMarkdown({...@@ -1712,9 +1802,6 @@ function ChatMarkdown({
1712 props.className,1802 props.className,
1713 );1803 );
1714 },1804 },
1715 img({ node: _node, title: _title, ...props }) {
1716 return <img {...props} />;
1717 },
1718 code({ node, children, className, ...props }) {1805 code({ node, children, className, ...props }) {
1719 if (node?.properties?.dataInlineCode != null) {1806 if (node?.properties?.dataInlineCode != null) {
1720 const codeText = nodeToPlainText(children);1807 const codeText = nodeToPlainText(children);
...@@ -1731,6 +1818,32 @@ function ChatMarkdown({...@@ -1731,6 +1818,32 @@ function ChatMarkdown({
1731 </code>1818 </code>
1732 );1819 );
1733 },1820 },
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 },
1734 table({ node: _node, ...props }) {1847 table({ node: _node, ...props }) {
1735 return <MarkdownTable {...props} />;1848 return <MarkdownTable {...props} />;
1736 },1849 },
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", () => {...@@ -90,6 +90,18 @@ describe("rewriteMarkdownFileUriHref", () => {
90 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69");90 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69");
91 });91 });
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
93 it("unwraps angle-bracketed file uri hrefs", () => {105 it("unwraps angle-bracketed file uri hrefs", () => {
94 expect(106 expect(
95 rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "),107 rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "),
...@@ -138,6 +150,18 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -138,6 +150,18 @@ describe("resolveMarkdownFileLinkTarget", () => {
138 );150 );
139 });151 });
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
141 it("formats tooltip display paths relative to the cwd when possible", () => {165 it("formats tooltip display paths relative to the cwd when possible", () => {
142 expect(166 expect(
143 resolveMarkdownFileLinkMeta(167 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+4-1
...@@ -108,7 +108,10 @@ function parseFileUrlHref(...@@ -108,7 +108,10 @@ function parseFileUrlHref(
108 const parsed = new URL(href);108 const parsed = new URL(href);
109 if (parsed.protocol.toLowerCase() !== "file:") return null;109 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;
112 if (rawPath.length === 0) return null;115 if (rawPath.length === 0) return null;
113116
114 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.117 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
packages/client-runtime/package.json+4
...@@ -15,6 +15,10 @@...@@ -15,6 +15,10 @@
15 "types": "./src/environment/index.ts",15 "types": "./src/environment/index.ts",
16 "default": "./src/environment/index.ts"16 "default": "./src/environment/index.ts"
17 },17 },
18 "./markdown-images": {
19 "types": "./src/markdownImages.ts",
20 "default": "./src/markdownImages.ts"
21 },
18 "./errors": {22 "./errors": {
19 "types": "./src/errors/index.ts",23 "types": "./src/errors/index.ts",
20 "default": "./src/errors/index.ts"24 "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}