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
logd0f855bfa91b1d961a17a02ec3e383f9671caba1
tree47707f6271514658806e515417f07bc07d5850b7
parent7451d17a69ff7585f302933beeae1037d2c708a8
signature Signed by PGP key B5690EEEBB952194

fix(web): size the chat image slot from server-reported dimensions (#10200)

Co-authored-by: Claude Code <noreply@anthropic.com>

3 files changed, 67 insertions(+), 5 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+22-2
......@@ -1235,9 +1235,15 @@ function markdownImageCopy(alt: string, src: string, title: string | undefined):
12351235 return `![${escapedAlt}](${src}${titleSuffix})`;
12361236}
12371237
1238/**
1239 * `maxHeightRem` folds a height cap into the width bound: `max-height` alone
1240 * would not feed back through `aspect-ratio` once `width` is definite, so a
1241 * tall image would keep a box wider than the picture it draws.
1242 */
12381243function authoredImageSizeStyle(
12391244 width: string | number | undefined,
12401245 height: string | number | undefined,
1246 maxHeightRem = 30,
12411247): CSSProperties | undefined {
12421248 const parsedWidth = Number(width);
12431249 const parsedHeight = Number(height);
......@@ -1248,7 +1254,7 @@ function authoredImageSizeStyle(
12481254 width: parsedWidth,
12491255 height: "auto",
12501256 aspectRatio: `${parsedWidth} / ${parsedHeight}`,
1251 maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`,
1257 maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`,
12521258 };
12531259 }
12541260 if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` };
......@@ -1522,6 +1528,8 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
15221528 readonly srcFragment?: string;
15231529 /** Reserve a slot while loading; off for images that share a line with text. */
15241530 readonly standalone?: boolean | undefined;
1531 /** Caps the box height in rem while keeping the image's ratio; 30 by default. */
1532 readonly maxHeightRem?: number | undefined;
15251533 readonly style?: CSSProperties | undefined;
15261534 readonly workspaceRoot?: string | undefined;
15271535 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
......@@ -1538,6 +1546,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
15381546 const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined;
15391547 const relativePath = reference?.relativePath;
15401548 const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null;
1549 // The server reads the pixel size from the file header, so the slot can be
1550 // the image's final box instead of a 16:9 guess. An authored size wins; a
1551 // caller's height cap shrinks the box while keeping the ratio.
1552 const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined;
1553 const maxHeightRem = props.maxHeightRem ?? 30;
1554 const style =
1555 props.style ??
1556 (knownSize
1557 ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem)
1558 : maxHeightRem !== 30
1559 ? { maxHeight: `${maxHeightRem}rem` }
1560 : undefined);
15411561 const actionsSource: MediaActionSource = {
15421562 kind: props.kind ?? "image",
15431563 name: props.alt || (props.kind ?? "image"),
......@@ -1581,7 +1601,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
15811601 copyMarkdown={props.copyMarkdown}
15821602 standalone={props.standalone ?? true}
15831603 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
1584 style={props.style}
1604 style={style}
15851605 actionsSource={actionsSource}
15861606 onImageExpand={props.onImageExpand}
15871607 />
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+44-2
......@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
55const testState = vi.hoisted(() => ({
66 resources: [] as Array<unknown>,
77 assetState: "success" as "success" | "loading" | "failure",
8 imageDimensions: undefined as { width: number; height: number } | undefined,
89}));
910
1011vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
......@@ -14,7 +15,11 @@ vi.mock("../assets/assetUrls", () => ({
1415 testState.resources.push(resource);
1516 if (testState.assetState === "loading") return { _tag: "Loading" };
1617 if (testState.assetState === "failure") return { _tag: "Failure" };
17 return { _tag: "Success", url: "https://signed.test/workspace-image.svg" };
18 return {
19 _tag: "Success",
20 url: "https://signed.test/workspace-image.svg",
21 ...(testState.imageDimensions ? { imageDimensions: testState.imageDimensions } : {}),
22 };
1823 },
1924}));
2025vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
......@@ -42,7 +47,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({
4247 useOpenChangeRequestLink: () => vi.fn(),
4348}));
4449
45import ChatMarkdown from "./ChatMarkdown";
50import ChatMarkdown, { ChatMarkdownAssetImage } from "./ChatMarkdown";
4651import { FileMarkdownPreview } from "./files/FileMarkdownPreview";
4752
4853const threadRef = {
......@@ -92,6 +97,7 @@ describe("ChatMarkdown workspace images", () => {
9297 beforeEach(() => {
9398 testState.resources = [];
9499 testState.assetState = "success";
100 testState.imageDimensions = undefined;
95101 });
96102
97103 it.each([
......@@ -245,6 +251,42 @@ describe("ChatMarkdown workspace images", () => {
245251 expect(html).toContain('<span id="user-content-diagram"');
246252 });
247253
254 it("sizes the slot from server-reported dimensions so a portrait image never grows", () => {
255 testState.imageDimensions = { width: 720, height: 1400 };
256
257 const style = firstInlineStyle(render("![shot](.t3/workspace-image.svg)"));
258
259 expect(style).toMatchObject({ width: "720px", "aspect-ratio": "720 / 1400" });
260 });
261
262 it("folds a caller's height cap into the width bound so the ratio holds", () => {
263 testState.imageDimensions = { width: 720, height: 1400 };
264
265 const html = renderToStaticMarkup(
266 <ChatMarkdownAssetImage
267 environmentId={threadRef.environmentId}
268 resource={{ _tag: "media-file", threadId: threadRef.threadId, path: "/shot.png" }}
269 alt="shot"
270 maxHeightRem={16}
271 />,
272 );
273
274 expect(firstInlineStyle(html)).toMatchObject({
275 "aspect-ratio": "720 / 1400",
276 "max-width": `min(100%, 30rem, ${(16 * 720) / 1400}rem)`,
277 });
278 });
279
280 it("lets an authored size override server-reported dimensions", () => {
281 testState.imageDimensions = { width: 720, height: 1400 };
282
283 const style = firstInlineStyle(
284 render('<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">'),
285 );
286
287 expect(style).toMatchObject({ width: "96px", "aspect-ratio": "96 / 128" });
288 });
289
248290 it("reserves a slot for an image that is alone in a list item", () => {
249291 expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video");
250292 });
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -3342,7 +3342,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
33423342 alt={viewedImage.alt}
33433343 srcFragment={viewedImage.srcFragment}
33443344 workspaceRoot={workspaceRoot}
3345 style={{ maxHeight: "16rem" }}
3345 maxHeightRem={16}
33463346 onImageExpand={onImageExpand}
33473347 />
33483348 </div>