authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:51:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:51:08-07:00
log7451d17a69ff7585f302933beeae1037d2c708a8
tree1f97c3b4bf74b44b123b56be3064f3f41d154b55
parente2e6ce6a29451e9fac612ab83f7a104b6d6475a3
signature Signed by PGP key B5690EEEBB952194

fix(mobile): size a chat image's frame before its bytes arrive (#10199)

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,6 +177,7 @@ import {
177} from "../files/filePath";177} from "../files/filePath";
178import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";178import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
179import {179import {
180 MarkdownImageAvailableWidthContext,
180 ThreadMarkdownImage,181 ThreadMarkdownImage,
181 ThreadMarkdownImageUnavailable,182 ThreadMarkdownImageUnavailable,
182 ThreadMarkdownImageView,183 ThreadMarkdownImageView,
...@@ -206,6 +207,10 @@ function formatMessageTime(input: string): string {...@@ -206,6 +207,10 @@ function formatMessageTime(input: string): string {
206// text fits at the current font settings. Larger accessibility text is measured.207// text fits at the current font settings. Larger accessibility text is measured.
207const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem208const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
208const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);209const 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.
212const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;
213const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;
209// Let neighboring rows move out of the new rows' space before showing their text.214// Let neighboring rows move out of the new rows' space before showing their text.
210const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(215const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
211 THREAD_DISCLOSURE_TRANSITION_MS,216 THREAD_DISCLOSURE_TRANSITION_MS,
...@@ -1331,6 +1336,8 @@ function renderFeedEntry(...@@ -1331,6 +1336,8 @@ function renderFeedEntry(
1331 readonly reviewCommentBubbleWidth: number;1336 readonly reviewCommentBubbleWidth: number;
1332 readonly themeAppearance: "light" | "dark";1337 readonly themeAppearance: "light" | "dark";
1333 readonly userBubbleMaxWidth: number;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 const entry = info.item;1343 const entry = info.item;
...@@ -1452,14 +1459,18 @@ function renderFeedEntry(...@@ -1452,14 +1459,18 @@ function renderFeedEntry(
1452 }}1459 }}
1453 >1460 >
1454 {message.text.trim().length > 0 ? (1461 {message.text.trim().length > 0 ? (
1455 <UserMessageContent1462 <MarkdownImageAvailableWidthContext
1456 text={renderedText}1463 value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
1457 markdownStyles={styles}1464 >
1458 reviewCommentColors={props.reviewCommentColors}1465 <UserMessageContent
1459 skills={props.skills}1466 text={renderedText}
1460 linkHandlers={props.markdownLinkHandlers}1467 markdownStyles={styles}
1461 renderImage={props.renderMarkdownImage}1468 reviewCommentColors={props.reviewCommentColors}
1462 />1469 skills={props.skills}
1470 linkHandlers={props.markdownLinkHandlers}
1471 renderImage={props.renderMarkdownImage}
1472 />
1473 </MarkdownImageAvailableWidthContext>
1463 ) : null}1474 ) : null}
1464 {attachments.map((attachment) => {1475 {attachments.map((attachment) => {
1465 return isImageAttachment(attachment) ? (1476 return isImageAttachment(attachment) ? (
...@@ -1516,14 +1527,16 @@ function renderFeedEntry(...@@ -1516,14 +1527,16 @@ function renderFeedEntry(
1516 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}1527 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
1517 >1528 >
1518 {renderedText.trim().length > 0 ? (1529 {renderedText.trim().length > 0 ? (
1519 <AssistantMarkdownContent1530 <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
1520 markdown={renderedText}1531 <AssistantMarkdownContent
1521 markdownStyles={styles}1532 markdown={renderedText}
1522 linkHandlers={props.markdownLinkHandlers}1533 markdownStyles={styles}
1523 onUseArtifactTemplate={props.onUseArtifactTemplate}1534 linkHandlers={props.markdownLinkHandlers}
1524 renderImage={props.renderMarkdownImage}1535 onUseArtifactTemplate={props.onUseArtifactTemplate}
1525 skills={props.skills}1536 renderImage={props.renderMarkdownImage}
1526 />1537 skills={props.skills}
1538 />
1539 </MarkdownImageAvailableWidthContext>
1527 ) : null}1540 ) : null}
1528 {attachments.map((attachment) => {1541 {attachments.map((attachment) => {
1529 return isImageAttachment(attachment) ? (1542 return isImageAttachment(attachment) ? (
...@@ -1942,6 +1955,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1942,6 +1955,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1942 });1955 });
1943 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);1956 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
1944 const userBubbleMaxWidth = contentWidth * 0.85;1957 const userBubbleMaxWidth = contentWidth * 0.85;
1958 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
1945 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);1959 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
1946 const insets = useSafeAreaInsets();1960 const insets = useSafeAreaInsets();
1947 const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;1961 const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;
...@@ -2620,6 +2634,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2620,6 +2634,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2620 reviewCommentBubbleWidth,2634 reviewCommentBubbleWidth,
2621 themeAppearance,2635 themeAppearance,
2622 userBubbleMaxWidth,2636 userBubbleMaxWidth,
2637 markdownContentWidth,
2623 skills: props.skills,2638 skills: props.skills,
2624 onUseArtifactTemplate: props.onUseArtifactTemplate,2639 onUseArtifactTemplate: props.onUseArtifactTemplate,
2625 })}2640 })}
...@@ -2641,6 +2656,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2641,6 +2656,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2641 reviewCommentBubbleWidth,2656 reviewCommentBubbleWidth,
2642 themeAppearance,2657 themeAppearance,
2643 userBubbleMaxWidth,2658 userBubbleMaxWidth,
2659 markdownContentWidth,
2644 onCopyWorkRow,2660 onCopyWorkRow,
2645 markdownLinkHandlers,2661 markdownLinkHandlers,
2646 onPressPreview,2662 onPressPreview,
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+35-12
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useId, useState } from "react";2import { createContext, useContext, useEffect, useId, useState } from "react";
3import {3import {
4 ActivityIndicator,4 ActivityIndicator,
5 Image,5 Image,
...@@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu";...@@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu";
15import { PresentationSource } from "../../components/NativePresentation";15import { PresentationSource } from "../../components/NativePresentation";
16import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";16import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
17import { useAssetUrlState } from "../../state/assets";17import { useAssetUrlState } from "../../state/assets";
18import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";18import {
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 */
32export const MarkdownImageAvailableWidthContext = createContext(0);
1933
20export function ThreadMarkdownImageView(props: {34export function ThreadMarkdownImageView(props: {
21 readonly uri: string | null;35 readonly uri: string | null;
22 readonly sourceKey: string;36 readonly sourceKey: string;
23 readonly unavailable: boolean;37 readonly unavailable: boolean;
24 readonly alt: string | null;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 readonly actionsSource?: MediaActionsSource;41 readonly actionsSource?: MediaActionsSource;
26 readonly onPressPreview: (source: FilePreviewSource) => void;42 readonly onPressPreview: (source: FilePreviewSource) => void;
27}) {43}) {
28 const sourceIdentifier = useId();44 const sourceIdentifier = useId();
29 const mediaActions = useMediaActions(props.actionsSource);45 const mediaActions = useMediaActions(props.actionsSource);
30 const [availableWidth, setAvailableWidth] = useState(0);46 const contextWidth = useContext(MarkdownImageAvailableWidthContext);
31 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);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 const [failedUri, setFailedUri] = useState<string | null>(null);53 const [failedUri, setFailedUri] = useState<string | null>(null);
3354
34 useEffect(() => {55 useEffect(() => {
35 setSourceSize(null);56 setDecodedSize(null);
36 }, [props.sourceKey]);57 }, [props.sourceKey]);
3758
38 useEffect(() => {59 useEffect(() => {
39 setFailedUri(null);60 setFailedUri(null);
40 }, [props.uri]);61 }, [props.uri]);
4162
42 const displaySize =63 // The decoded size is what the platform actually drew, so it wins over the
43 sourceSize === null64 // 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 ? null68 ? null
45 : resolveMarkdownImageDisplaySize({69 : resolveMarkdownImageDisplaySize({
46 sourceWidth: sourceSize.width,70 sourceWidth: sourceSize.width,
...@@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: {...@@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: {
5478
55 return (79 return (
56 <View80 <View
57 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}81 onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
58 style={{ alignSelf: "stretch", gap: 6 }}82 style={{ alignSelf: "stretch", gap: 6 }}
59 >83 >
60 {props.uri === null || failed ? (84 {props.uri === null || failed ? (
...@@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: {...@@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: {
97 >121 >
98 <View122 <View
99 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"123 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
100 style={{124 style={frameStyle}
101 ...frameStyle,
102 }}
103 >125 >
104 <ThreadMarkdownImageRequest126 <ThreadMarkdownImageRequest
105 key={props.uri}127 key={props.uri}
106 uri={props.uri}128 uri={props.uri}
107 onLoad={setSourceSize}129 onLoad={setDecodedSize}
108 onError={() => setFailedUri(props.uri)}130 onError={() => setFailedUri(props.uri)}
109 />131 />
110 </View>132 </View>
...@@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: {...@@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: {
173 : `workspace:${props.resource.path}`195 : `workspace:${props.resource.path}`
174 }196 }
175 unavailable={assetUrl._tag === "Failure"}197 unavailable={assetUrl._tag === "Failure"}
198 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
176 alt={props.alt}199 alt={props.alt}
177 actionsSource={props.actionsSource}200 actionsSource={props.actionsSource}
178 onPressPreview={props.onPressPreview}201 onPressPreview={props.onPressPreview}