| author | |
| committer | |
| log | 77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade |
| tree | 6c1af58206584dd7cc4941cacf9fd167f6dd5ac6 |
| parent | dedcd99a9d16240327ce763b885b326aff607bdb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { useEffect, useState } from "react"; | 1 | import { createContext, useContext, useEffect, useState } from "react"; |
| 2 | import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; | 2 | import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; |
| 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | ||
| ... | @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios | ... | @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios |
| 9 | import type { | 9 | import 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"; |
| 15 | 16 | ||
| 17 | /** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ | ||
| 18 | export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null); | ||
| 19 | |||
| 16 | type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; | 20 | type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; |
| 17 | 21 | ||
| 18 | const highlightedCodeCache = new Map<string, HighlightedCode>(); | 22 | const 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 | } |
| 393 | 398 | ||
| 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 | <Image | 412 | <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"; |
| 11 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; | 11 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; |
| 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; |
| 13 | import type { | 13 | import type { |
| 14 | SelectableMarkdownSkill, | 14 | SelectableMarkdownSkill, |
| ... | @@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = []; | ... | @@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = []; |
| 20 | export type { | 20 | export 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]); |
| 60 | 63 | ||
| 61 | return ( | 64 | return ( |
| 62 | // A percentage width here creates a cyclic intrinsic measurement inside | 65 | <MarkdownImageRendererContext.Provider value={renderImage ?? null}> |
| 63 | // shrink-to-fit containers such as user-message bubbles. Yoga then gives | 66 | {/* A percentage width here creates a cyclic intrinsic measurement inside |
| 64 | // the native text node an unbounded second pass and the parent only clips | 67 | 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 | <NativeMarkdownBlock | 73 | 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 | <NativeMarkdownSelectableText | 81 | ) : ( |
| 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 | ); | ||
| 84 | 88 | ||
| 85 | return ( | 89 | return ( |
| 86 | <View | 90 | <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 |
| 3 | export type { | 3 | export 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 | } |
| 38 | 38 | ||
| 39 | export 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 | */ | ||
| 51 | export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; | ||
| 52 | |||
| 39 | export interface SelectableMarkdownTextProps { | 53 | export 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"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | ||
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 6 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 7 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | ... | @@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 54 | import { | 55 | import { |
| 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"; |
| 103 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 105 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 104 | import { useAssetUrl } from "../../state/assets"; | 106 | import { useAssetUrl, useAssetUrlState } from "../../state/assets"; |
| 105 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 107 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 106 | 108 | ||
| 107 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { | 109 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { |
| ... | @@ -194,6 +196,98 @@ function MessageAttachmentImage(props: { | ... | @@ -194,6 +196,98 @@ function MessageAttachmentImage(props: { |
| 194 | ); | 196 | ); |
| 195 | } | 197 | } |
| 196 | 198 | ||
| 199 | /** Markdown image whose src is a workspace file — loads through a signed asset URL. */ | ||
| 200 | function 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 | |||
| 266 | function 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 | |||
| 197 | const MARKDOWN_MONO_FONT = Platform.select({ | 291 | const 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 | } |
| 411 | 505 | ||
| 412 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { | 506 | function 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 | <Markdown | 1069 | <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 | preserveSoftBreaks | 1163 | 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 | preserveSoftBreaks | 1205 | preserveSoftBreaks |
| 1095 | onLinkPress={props.onLinkPress} | 1206 | onLinkPress={props.onLinkPress} |
| 1207 | renderImage={props.renderImage} | ||
| 1096 | /> | 1208 | /> |
| 1097 | ) : ( | 1209 | ) : ( |
| 1098 | <Markdown | 1210 | <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 | ); |
| 1837 | 1972 |
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" |
| 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 9 | 9 | ||
| 10 | export type { | 10 | export 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/ |
| 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 4 | 4 | ||
| 5 | export type { | 5 | export 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 | ); |
| 14 | 14 | ||
| 15 | export function useAssetUrl( | 15 | export type AssetUrlState = |
| 16 | | { readonly _tag: "Loading" } | ||
| 17 | | { readonly _tag: "Failure" } | ||
| 18 | | { readonly _tag: "Success"; readonly url: string }; | ||
| 19 | |||
| 20 | export 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 === null | 26 | environmentId === null || resource === null |
| 22 | ? EMPTY_ASSET_URL_ATOM | 27 | ? 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 | |||
| 40 | export 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"; |
| 22 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | ||
| 22 | import * as Cause from "effect/Cause"; | 23 | import * as Cause from "effect/Cause"; |
| 23 | import { AsyncResult } from "effect/unstable/reactivity"; | 24 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 24 | import React, { | 25 | import 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"; |
| 85 | import { readLocalApi } from "../localApi"; | 86 | import { readLocalApi } from "../localApi"; |
| 87 | import { useAssetUrlState } from "../assets/assetUrls"; | ||
| 86 | import { cn } from "../lib/utils"; | 88 | import { cn } from "../lib/utils"; |
| 87 | import { useRightPanelStore } from "../rightPanelStore"; | 89 | import { useRightPanelStore } from "../rightPanelStore"; |
| 88 | import { useActiveEnvironmentId } from "../state/entities"; | 90 | import { 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 | } |
| 181 | 183 | ||
| 184 | type 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. */ | ||
| 192 | function 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 | |||
| 182 | const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | 214 | const 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]; |
| 195 | 228 | ||
| ... | @@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | ... | @@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 212 | 245 | ||
| 213 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | 246 | const 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"]>; |
| 217 | 251 | ||
| ... | @@ -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 | }); |
| 945 | 979 | ||
| 980 | const 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. | ||
| 985 | const 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 | |||
| 990 | function 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. */ | ||
| 1000 | const 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 | |||
| 946 | function leadingExternalLinkTextLength(text: string): number { | 1036 | function 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 @@ | ||
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const testState = vi.hoisted(() => ({ | ||
| 6 | resources: [] as Array<unknown>, | ||
| 7 | assetState: "success" as "success" | "loading", | ||
| 8 | })); | ||
| 9 | |||
| 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | ||
| 11 | vi.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 | })); | ||
| 19 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | ||
| 20 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); | ||
| 21 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | ||
| 22 | vi.mock("../state/session", async (importOriginal) => ({ | ||
| 23 | ...(await importOriginal<typeof import("../state/session")>()), | ||
| 24 | usePreparedConnection: () => ({ _tag: "Loading" }), | ||
| 25 | })); | ||
| 26 | vi.mock("../state/entities", () => ({ | ||
| 27 | useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), | ||
| 28 | })); | ||
| 29 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); | ||
| 30 | vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); | ||
| 31 | |||
| 32 | import ChatMarkdown from "./ChatMarkdown"; | ||
| 33 | |||
| 34 | const threadRef = { | ||
| 35 | environmentId: EnvironmentId.make("env-windows"), | ||
| 36 | threadId: ThreadId.make("thread-windows"), | ||
| 37 | }; | ||
| 38 | |||
| 39 | function render(markdown: string): string { | ||
| 40 | return renderToStaticMarkup( | ||
| 41 | <ChatMarkdown cwd={"C:\\Users\\shawn\\project"} threadRef={threadRef} text={markdown} />, | ||
| 42 | ); | ||
| 43 | } | ||
| 44 | |||
| 45 | function renderWithoutThread(markdown: string): string { | ||
| 46 | return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />); | ||
| 47 | } | ||
| 48 | |||
| 49 | describe("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 | "", | ||
| 60 | ``, | ||
| 61 | ``, | ||
| 62 | "", | ||
| 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(""); | ||
| 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 | "", | ||
| 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(""); | ||
| 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(""); | ||
| 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 | }); |
| 92 | 92 | ||
| 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 | }); |
| 140 | 152 | ||
| 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; |
| 110 | 110 | ||
| 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; |
| 113 | 116 | ||
| 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { classifyMarkdownImageSource } from "./markdownImages.js"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; | ||
| 2 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; | ||
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | ||
| 4 | |||
| 5 | export type MarkdownImageSource = | ||
| 6 | | { readonly _tag: "Direct"; readonly uri: string } | ||
| 7 | | { readonly _tag: "WorkspaceFile"; readonly path: string } | ||
| 8 | | { readonly _tag: "Blocked" }; | ||
| 9 | |||
| 10 | function safeDecode(value: string): string { | ||
| 11 | try { | ||
| 12 | return decodeURIComponent(value); | ||
| 13 | } catch { | ||
| 14 | return value; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | function normalizeSource(value: string): string { | ||
| 19 | const trimmed = value.trim(); | ||
| 20 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | ||
| 21 | } | ||
| 22 | |||
| 23 | function normalizeWindowsDrivePath(value: string): string { | ||
| 24 | return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; | ||
| 25 | } | ||
| 26 | |||
| 27 | function 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 | |||
| 45 | function 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 | |||
| 54 | function 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 | */ | ||
| 68 | export 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 | } | ||