# Files changed · PR \#10199 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10199/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): size a chat image's frame before its bytes arrive](https://git.cafe/pingdotgg/t3code/pull/10199.md)

Version: 2

Head commit: cced7aadcbc12ff44f5dc2dd5613e72fb4e34b41

Base commit: 0c0d4ea243cbc281d399a60588a9419eb9a5a646

Comparison base: 0c0d4ea243cbc281d399a60588a9419eb9a5a646

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +32; −16

```diff
@@ -177,6 +177,7 @@
 } from "../files/filePath";
 import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
 import {
+  MarkdownImageAvailableWidthContext,
   ThreadMarkdownImage,
   ThreadMarkdownImageUnavailable,
   ThreadMarkdownImageView,
@@ -206,6 +207,10 @@
 // text fits at the current font settings. Larger accessibility text is measured.
 const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
 const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
+// Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on
+// assistant rows. Images size their frame from these before their own layout.
+const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;
+const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;
 // Let neighboring rows move out of the new rows' space before showing their text.
 const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
   THREAD_DISCLOSURE_TRANSITION_MS,
@@ -1331,6 +1336,8 @@
     readonly reviewCommentBubbleWidth: number;
     readonly themeAppearance: "light" | "dark";
     readonly userBubbleMaxWidth: number;
+    /** Width assistant markdown lays out in, so images can size their frame before layout. */
+    readonly markdownContentWidth: number;
   },
 ) {
   const entry = info.item;
@@ -1452,14 +1459,18 @@
             }}
           >
             {message.text.trim().length > 0 ? (
-              <UserMessageContent
-                text={renderedText}
-                markdownStyles={styles}
-                reviewCommentColors={props.reviewCommentColors}
-                skills={props.skills}
-                linkHandlers={props.markdownLinkHandlers}
-                renderImage={props.renderMarkdownImage}
-              />
+              <MarkdownImageAvailableWidthContext
+                value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
+              >
+                <UserMessageContent
+                  text={renderedText}
+                  markdownStyles={styles}
+                  reviewCommentColors={props.reviewCommentColors}
+                  skills={props.skills}
+                  linkHandlers={props.markdownLinkHandlers}
+                  renderImage={props.renderMarkdownImage}
+                />
+              </MarkdownImageAvailableWidthContext>
             ) : null}
             {attachments.map((attachment) => {
               return isImageAttachment(attachment) ? (
@@ -1516,14 +1527,16 @@
         {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
       >
         {renderedText.trim().length > 0 ? (
-          <AssistantMarkdownContent
-            markdown={renderedText}
-            markdownStyles={styles}
-            linkHandlers={props.markdownLinkHandlers}
-            onUseArtifactTemplate={props.onUseArtifactTemplate}
-            renderImage={props.renderMarkdownImage}
-            skills={props.skills}
+          <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
-          />
+            <AssistantMarkdownContent
+              markdown={renderedText}
+              markdownStyles={styles}
+              linkHandlers={props.markdownLinkHandlers}
+              onUseArtifactTemplate={props.onUseArtifactTemplate}
+              renderImage={props.renderMarkdownImage}
+              skills={props.skills}
+            />
+          </MarkdownImageAvailableWidthContext>
         ) : null}
         {attachments.map((attachment) => {
           return isImageAttachment(attachment) ? (
@@ -1942,6 +1955,7 @@
   });
   const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
   const userBubbleMaxWidth = contentWidth * 0.85;
+  const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
   const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
   const insets = useSafeAreaInsets();
   const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;
@@ -2620,6 +2634,7 @@
             reviewCommentBubbleWidth,
             themeAppearance,
             userBubbleMaxWidth,
+            markdownContentWidth,
             skills: props.skills,
             onUseArtifactTemplate: props.onUseArtifactTemplate,
           })}
@@ -2641,6 +2656,7 @@
       reviewCommentBubbleWidth,
       themeAppearance,
       userBubbleMaxWidth,
+      markdownContentWidth,
       onCopyWorkRow,
       markdownLinkHandlers,
       onPressPreview,
```

## apps/mobile/src/features/threads/ThreadMarkdownImage.tsx

Status: modified; +35; −12

```diff
@@ -1,5 +1,5 @@
 import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
-import { useEffect, useId, useState } from "react";
+import { createContext, useContext, useEffect, useId, useState } from "react";
 import {
   ActivityIndicator,
   Image,
@@ -15,32 +15,56 @@
 import { PresentationSource } from "../../components/NativePresentation";
 import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
 import { useAssetUrlState } from "../../state/assets";
-import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
+import {
+  MARKDOWN_IMAGE_MAX_WIDTH,
+  type MarkdownImageDisplaySize,
+  resolveMarkdownImageDisplaySize,
+} from "./markdownImageSize";
 
+/**
+ * Width the feed lays markdown out in. The feed already knows this from its
+ * viewport, so an image can size its frame on the first render instead of
+ * waiting for its own onLayout, which would change the row's height once
+ * more after the list has positioned the rows below it. It is an upper
+ * bound: a list item or blockquote indents its column, and the measured
+ * width takes over once it is known.
+ */
+export const MarkdownImageAvailableWidthContext = createContext(0);
+
 export function ThreadMarkdownImageView(props: {
   readonly uri: string | null;
   readonly sourceKey: string;
   readonly unavailable: boolean;
   readonly alt: string | null;
+  /** Pixel size from the server, when it could read the header; the frame is final from the first render. */
+  readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
   readonly actionsSource?: MediaActionsSource;
   readonly onPressPreview: (source: FilePreviewSource) => void;
 }) {
   const sourceIdentifier = useId();
   const mediaActions = useMediaActions(props.actionsSource);
+  const contextWidth = useContext(MarkdownImageAvailableWidthContext);
+  const [measuredWidth, setMeasuredWidth] = useState(0);
+  const availableWidth =
+    measuredWidth > 0 && contextWidth > 0
-  const [availableWidth, setAvailableWidth] = useState(0);
+      ? Math.min(contextWidth, measuredWidth)
-  const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
+      : contextWidth || measuredWidth;
+  const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null);
   const [failedUri, setFailedUri] = useState<string | null>(null);
 
   useEffect(() => {
-    setSourceSize(null);
+    setDecodedSize(null);
   }, [props.sourceKey]);
 
   useEffect(() => {
     setFailedUri(null);
   }, [props.uri]);
 
+  // The decoded size is what the platform actually drew, so it wins over the
+  // server's header hint once it exists.
+  const sourceSize = decodedSize ?? props.knownSize ?? null;
+  const displaySize: MarkdownImageDisplaySize | null =
+    sourceSize === null || availableWidth <= 0
-  const displaySize =
-    sourceSize === null
       ? null
       : resolveMarkdownImageDisplaySize({
           sourceWidth: sourceSize.width,
@@ -54,7 +78,7 @@
 
   return (
     <View
-      onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
+      onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
       style={{ alignSelf: "stretch", gap: 6 }}
     >
       {props.uri === null || failed ? (
@@ -97,14 +121,12 @@
             >
               <View
                 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
+                style={frameStyle}
-                style={{
-                  ...frameStyle,
-                }}
               >
                 <ThreadMarkdownImageRequest
                   key={props.uri}
                   uri={props.uri}
+                  onLoad={setDecodedSize}
-                  onLoad={setSourceSize}
                   onError={() => setFailedUri(props.uri)}
                 />
               </View>
@@ -173,6 +195,7 @@
           : `workspace:${props.resource.path}`
       }
       unavailable={assetUrl._tag === "Failure"}
+      knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
       alt={props.alt}
       actionsSource={props.actionsSource}
       onPressPreview={props.onPressPreview}
```
