| author | |
| committer | |
| log | 7451d17a69ff7585f302933beeae1037d2c708a8 |
| tree | 1f97c3b4bf74b44b123b56be3064f3f41d154b55 |
| parent | e2e6ce6a29451e9fac612ab83f7a104b6d6475a3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>2 files changed, 67 insertions(+), 28 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+32-16| ... | ... | @@ -177,6 +177,7 @@ import { |
| 177 | 177 | } from "../files/filePath"; |
| 178 | 178 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; |
| 179 | 179 | import { |
| 180 | MarkdownImageAvailableWidthContext, | |
| 180 | 181 | ThreadMarkdownImage, |
| 181 | 182 | ThreadMarkdownImageUnavailable, |
| 182 | 183 | ThreadMarkdownImageView, |
| ... | ... | @@ -206,6 +207,10 @@ function formatMessageTime(input: string): string { |
| 206 | 207 | // text fits at the current font settings. Larger accessibility text is measured. |
| 207 | 208 | const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem |
| 208 | 209 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 210 | // Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on | |
| 211 | // assistant rows. Images size their frame from these before their own layout. | |
| 212 | const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; | |
| 213 | const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; | |
| 209 | 214 | // Let neighboring rows move out of the new rows' space before showing their text. |
| 210 | 215 | const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( |
| 211 | 216 | THREAD_DISCLOSURE_TRANSITION_MS, |
| ... | ... | @@ -1331,6 +1336,8 @@ function renderFeedEntry( |
| 1331 | 1336 | readonly reviewCommentBubbleWidth: number; |
| 1332 | 1337 | readonly themeAppearance: "light" | "dark"; |
| 1333 | 1338 | readonly userBubbleMaxWidth: number; |
| 1339 | /** Width assistant markdown lays out in, so images can size their frame before layout. */ | |
| 1340 | readonly markdownContentWidth: number; | |
| 1334 | 1341 | }, |
| 1335 | 1342 | ) { |
| 1336 | 1343 | const entry = info.item; |
| ... | ... | @@ -1452,14 +1459,18 @@ function renderFeedEntry( |
| 1452 | 1459 | }} |
| 1453 | 1460 | > |
| 1454 | 1461 | {message.text.trim().length > 0 ? ( |
| 1455 | <UserMessageContent | |
| 1456 | text={renderedText} | |
| 1457 | markdownStyles={styles} | |
| 1458 | reviewCommentColors={props.reviewCommentColors} | |
| 1459 | skills={props.skills} | |
| 1460 | linkHandlers={props.markdownLinkHandlers} | |
| 1461 | renderImage={props.renderMarkdownImage} | |
| 1462 | /> | |
| 1462 | <MarkdownImageAvailableWidthContext | |
| 1463 | value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2} | |
| 1464 | > | |
| 1465 | <UserMessageContent | |
| 1466 | text={renderedText} | |
| 1467 | markdownStyles={styles} | |
| 1468 | reviewCommentColors={props.reviewCommentColors} | |
| 1469 | skills={props.skills} | |
| 1470 | linkHandlers={props.markdownLinkHandlers} | |
| 1471 | renderImage={props.renderMarkdownImage} | |
| 1472 | /> | |
| 1473 | </MarkdownImageAvailableWidthContext> | |
| 1463 | 1474 | ) : null} |
| 1464 | 1475 | {attachments.map((attachment) => { |
| 1465 | 1476 | return isImageAttachment(attachment) ? ( |
| ... | ... | @@ -1516,14 +1527,16 @@ function renderFeedEntry( |
| 1516 | 1527 | {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} |
| 1517 | 1528 | > |
| 1518 | 1529 | {renderedText.trim().length > 0 ? ( |
| 1519 | <AssistantMarkdownContent | |
| 1520 | markdown={renderedText} | |
| 1521 | markdownStyles={styles} | |
| 1522 | linkHandlers={props.markdownLinkHandlers} | |
| 1523 | onUseArtifactTemplate={props.onUseArtifactTemplate} | |
| 1524 | renderImage={props.renderMarkdownImage} | |
| 1525 | skills={props.skills} | |
| 1526 | /> | |
| 1530 | <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}> | |
| 1531 | <AssistantMarkdownContent | |
| 1532 | markdown={renderedText} | |
| 1533 | markdownStyles={styles} | |
| 1534 | linkHandlers={props.markdownLinkHandlers} | |
| 1535 | onUseArtifactTemplate={props.onUseArtifactTemplate} | |
| 1536 | renderImage={props.renderMarkdownImage} | |
| 1537 | skills={props.skills} | |
| 1538 | /> | |
| 1539 | </MarkdownImageAvailableWidthContext> | |
| 1527 | 1540 | ) : null} |
| 1528 | 1541 | {attachments.map((attachment) => { |
| 1529 | 1542 | return isImageAttachment(attachment) ? ( |
| ... | ... | @@ -1942,6 +1955,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1942 | 1955 | }); |
| 1943 | 1956 | const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); |
| 1944 | 1957 | const userBubbleMaxWidth = contentWidth * 0.85; |
| 1958 | const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); | |
| 1945 | 1959 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 1946 | 1960 | const insets = useSafeAreaInsets(); |
| 1947 | 1961 | const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; |
| ... | ... | @@ -2620,6 +2634,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2620 | 2634 | reviewCommentBubbleWidth, |
| 2621 | 2635 | themeAppearance, |
| 2622 | 2636 | userBubbleMaxWidth, |
| 2637 | markdownContentWidth, | |
| 2623 | 2638 | skills: props.skills, |
| 2624 | 2639 | onUseArtifactTemplate: props.onUseArtifactTemplate, |
| 2625 | 2640 | })} |
| ... | ... | @@ -2641,6 +2656,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2641 | 2656 | reviewCommentBubbleWidth, |
| 2642 | 2657 | themeAppearance, |
| 2643 | 2658 | userBubbleMaxWidth, |
| 2659 | markdownContentWidth, | |
| 2644 | 2660 | onCopyWorkRow, |
| 2645 | 2661 | markdownLinkHandlers, |
| 2646 | 2662 | onPressPreview, |
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+35-12| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { useEffect, useId, useState } from "react"; | |
| 2 | import { createContext, useContext, useEffect, useId, useState } from "react"; | |
| 3 | 3 | import { |
| 4 | 4 | ActivityIndicator, |
| 5 | 5 | Image, |
| ... | ... | @@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 15 | 15 | import { PresentationSource } from "../../components/NativePresentation"; |
| 16 | 16 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; |
| 17 | 17 | import { useAssetUrlState } from "../../state/assets"; |
| 18 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; | |
| 18 | import { | |
| 19 | MARKDOWN_IMAGE_MAX_WIDTH, | |
| 20 | type MarkdownImageDisplaySize, | |
| 21 | resolveMarkdownImageDisplaySize, | |
| 22 | } from "./markdownImageSize"; | |
| 23 | ||
| 24 | /** | |
| 25 | * Width the feed lays markdown out in. The feed already knows this from its | |
| 26 | * viewport, so an image can size its frame on the first render instead of | |
| 27 | * waiting for its own onLayout, which would change the row's height once | |
| 28 | * more after the list has positioned the rows below it. It is an upper | |
| 29 | * bound: a list item or blockquote indents its column, and the measured | |
| 30 | * width takes over once it is known. | |
| 31 | */ | |
| 32 | export const MarkdownImageAvailableWidthContext = createContext(0); | |
| 19 | 33 | |
| 20 | 34 | export function ThreadMarkdownImageView(props: { |
| 21 | 35 | readonly uri: string | null; |
| 22 | 36 | readonly sourceKey: string; |
| 23 | 37 | readonly unavailable: boolean; |
| 24 | 38 | readonly alt: string | null; |
| 39 | /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ | |
| 40 | readonly knownSize?: { readonly width: number; readonly height: number } | undefined; | |
| 25 | 41 | readonly actionsSource?: MediaActionsSource; |
| 26 | 42 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 27 | 43 | }) { |
| 28 | 44 | const sourceIdentifier = useId(); |
| 29 | 45 | const mediaActions = useMediaActions(props.actionsSource); |
| 30 | const [availableWidth, setAvailableWidth] = useState(0); | |
| 31 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); | |
| 46 | const contextWidth = useContext(MarkdownImageAvailableWidthContext); | |
| 47 | const [measuredWidth, setMeasuredWidth] = useState(0); | |
| 48 | const availableWidth = | |
| 49 | measuredWidth > 0 && contextWidth > 0 | |
| 50 | ? Math.min(contextWidth, measuredWidth) | |
| 51 | : contextWidth || measuredWidth; | |
| 52 | const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); | |
| 32 | 53 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| 33 | 54 | |
| 34 | 55 | useEffect(() => { |
| 35 | setSourceSize(null); | |
| 56 | setDecodedSize(null); | |
| 36 | 57 | }, [props.sourceKey]); |
| 37 | 58 | |
| 38 | 59 | useEffect(() => { |
| 39 | 60 | setFailedUri(null); |
| 40 | 61 | }, [props.uri]); |
| 41 | 62 | |
| 42 | const displaySize = | |
| 43 | sourceSize === null | |
| 63 | // The decoded size is what the platform actually drew, so it wins over the | |
| 64 | // server's header hint once it exists. | |
| 65 | const sourceSize = decodedSize ?? props.knownSize ?? null; | |
| 66 | const displaySize: MarkdownImageDisplaySize | null = | |
| 67 | sourceSize === null || availableWidth <= 0 | |
| 44 | 68 | ? null |
| 45 | 69 | : resolveMarkdownImageDisplaySize({ |
| 46 | 70 | sourceWidth: sourceSize.width, |
| ... | ... | @@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: { |
| 54 | 78 | |
| 55 | 79 | return ( |
| 56 | 80 | <View |
| 57 | onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} | |
| 81 | onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)} | |
| 58 | 82 | style={{ alignSelf: "stretch", gap: 6 }} |
| 59 | 83 | > |
| 60 | 84 | {props.uri === null || failed ? ( |
| ... | ... | @@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: { |
| 97 | 121 | > |
| 98 | 122 | <View |
| 99 | 123 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" |
| 100 | style={{ | |
| 101 | ...frameStyle, | |
| 102 | }} | |
| 124 | style={frameStyle} | |
| 103 | 125 | > |
| 104 | 126 | <ThreadMarkdownImageRequest |
| 105 | 127 | key={props.uri} |
| 106 | 128 | uri={props.uri} |
| 107 | onLoad={setSourceSize} | |
| 129 | onLoad={setDecodedSize} | |
| 108 | 130 | onError={() => setFailedUri(props.uri)} |
| 109 | 131 | /> |
| 110 | 132 | </View> |
| ... | ... | @@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: { |
| 173 | 195 | : `workspace:${props.resource.path}` |
| 174 | 196 | } |
| 175 | 197 | unavailable={assetUrl._tag === "Failure"} |
| 198 | knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} | |
| 176 | 199 | alt={props.alt} |
| 177 | 200 | actionsSource={props.actionsSource} |
| 178 | 201 | onPressPreview={props.onPressPreview} |