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 {
177177} from "../files/filePath";
178178import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
179179import {
180 MarkdownImageAvailableWidthContext,
180181 ThreadMarkdownImage,
181182 ThreadMarkdownImageUnavailable,
182183 ThreadMarkdownImageView,
......@@ -206,6 +207,10 @@ function formatMessageTime(input: string): string {
206207// text fits at the current font settings. Larger accessibility text is measured.
207208const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
208209const 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;
209214// Let neighboring rows move out of the new rows' space before showing their text.
210215const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
211216 THREAD_DISCLOSURE_TRANSITION_MS,
......@@ -1331,6 +1336,8 @@ function renderFeedEntry(
13311336 readonly reviewCommentBubbleWidth: number;
13321337 readonly themeAppearance: "light" | "dark";
13331338 readonly userBubbleMaxWidth: number;
1339 /** Width assistant markdown lays out in, so images can size their frame before layout. */
1340 readonly markdownContentWidth: number;
13341341 },
13351342) {
13361343 const entry = info.item;
......@@ -1452,14 +1459,18 @@ function renderFeedEntry(
14521459 }}
14531460 >
14541461 {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>
14631474 ) : null}
14641475 {attachments.map((attachment) => {
14651476 return isImageAttachment(attachment) ? (
......@@ -1516,14 +1527,16 @@ function renderFeedEntry(
15161527 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
15171528 >
15181529 {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>
15271540 ) : null}
15281541 {attachments.map((attachment) => {
15291542 return isImageAttachment(attachment) ? (
......@@ -1942,6 +1955,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19421955 });
19431956 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
19441957 const userBubbleMaxWidth = contentWidth * 0.85;
1958 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
19451959 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
19461960 const insets = useSafeAreaInsets();
19471961 const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;
......@@ -2620,6 +2634,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26202634 reviewCommentBubbleWidth,
26212635 themeAppearance,
26222636 userBubbleMaxWidth,
2637 markdownContentWidth,
26232638 skills: props.skills,
26242639 onUseArtifactTemplate: props.onUseArtifactTemplate,
26252640 })}
......@@ -2641,6 +2656,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26412656 reviewCommentBubbleWidth,
26422657 themeAppearance,
26432658 userBubbleMaxWidth,
2659 markdownContentWidth,
26442660 onCopyWorkRow,
26452661 markdownLinkHandlers,
26462662 onPressPreview,
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+35-12
......@@ -1,5 +1,5 @@
11import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useId, useState } from "react";
2import { createContext, useContext, useEffect, useId, useState } from "react";
33import {
44 ActivityIndicator,
55 Image,
......@@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1515import { PresentationSource } from "../../components/NativePresentation";
1616import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
1717import { 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
2034export function ThreadMarkdownImageView(props: {
2135 readonly uri: string | null;
2236 readonly sourceKey: string;
2337 readonly unavailable: boolean;
2438 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;
2541 readonly actionsSource?: MediaActionsSource;
2642 readonly onPressPreview: (source: FilePreviewSource) => void;
2743}) {
2844 const sourceIdentifier = useId();
2945 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);
3253 const [failedUri, setFailedUri] = useState<string | null>(null);
3354
3455 useEffect(() => {
35 setSourceSize(null);
56 setDecodedSize(null);
3657 }, [props.sourceKey]);
3758
3859 useEffect(() => {
3960 setFailedUri(null);
4061 }, [props.uri]);
4162
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
4468 ? null
4569 : resolveMarkdownImageDisplaySize({
4670 sourceWidth: sourceSize.width,
......@@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: {
5478
5579 return (
5680 <View
57 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
81 onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
5882 style={{ alignSelf: "stretch", gap: 6 }}
5983 >
6084 {props.uri === null || failed ? (
......@@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: {
97121 >
98122 <View
99123 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
100 style={{
101 ...frameStyle,
102 }}
124 style={frameStyle}
103125 >
104126 <ThreadMarkdownImageRequest
105127 key={props.uri}
106128 uri={props.uri}
107 onLoad={setSourceSize}
129 onLoad={setDecodedSize}
108130 onError={() => setFailedUri(props.uri)}
109131 />
110132 </View>
......@@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: {
173195 : `workspace:${props.resource.path}`
174196 }
175197 unavailable={assetUrl._tag === "Failure"}
198 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
176199 alt={props.alt}
177200 actionsSource={props.actionsSource}
178201 onPressPreview={props.onPressPreview}