| author | |
| committer | |
| log | d0f855bfa91b1d961a17a02ec3e383f9671caba1 |
| tree | 47707f6271514658806e515417f07bc07d5850b7 |
| parent | 7451d17a69ff7585f302933beeae1037d2c708a8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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): |
| 1235 | 1235 | return ``; |
| 1236 | 1236 | } |
| 1237 | 1237 | |
| 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 | */ | |
| 1238 | 1243 | function authoredImageSizeStyle( |
| 1239 | 1244 | width: string | number | undefined, |
| 1240 | 1245 | height: string | number | undefined, |
| 1246 | maxHeightRem = 30, | |
| 1241 | 1247 | ): CSSProperties | undefined { |
| 1242 | 1248 | const parsedWidth = Number(width); |
| 1243 | 1249 | const parsedHeight = Number(height); |
| ... | ... | @@ -1248,7 +1254,7 @@ function authoredImageSizeStyle( |
| 1248 | 1254 | width: parsedWidth, |
| 1249 | 1255 | height: "auto", |
| 1250 | 1256 | aspectRatio: `${parsedWidth} / ${parsedHeight}`, |
| 1251 | maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`, | |
| 1257 | maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, | |
| 1252 | 1258 | }; |
| 1253 | 1259 | } |
| 1254 | 1260 | if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; |
| ... | ... | @@ -1522,6 +1528,8 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1522 | 1528 | readonly srcFragment?: string; |
| 1523 | 1529 | /** Reserve a slot while loading; off for images that share a line with text. */ |
| 1524 | 1530 | 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; | |
| 1525 | 1533 | readonly style?: CSSProperties | undefined; |
| 1526 | 1534 | readonly workspaceRoot?: string | undefined; |
| 1527 | 1535 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| ... | ... | @@ -1538,6 +1546,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1538 | 1546 | const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; |
| 1539 | 1547 | const relativePath = reference?.relativePath; |
| 1540 | 1548 | 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); | |
| 1541 | 1561 | const actionsSource: MediaActionSource = { |
| 1542 | 1562 | kind: props.kind ?? "image", |
| 1543 | 1563 | name: props.alt || (props.kind ?? "image"), |
| ... | ... | @@ -1581,7 +1601,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1581 | 1601 | copyMarkdown={props.copyMarkdown} |
| 1582 | 1602 | standalone={props.standalone ?? true} |
| 1583 | 1603 | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} |
| 1584 | style={props.style} | |
| 1604 | style={style} | |
| 1585 | 1605 | actionsSource={actionsSource} |
| 1586 | 1606 | onImageExpand={props.onImageExpand} |
| 1587 | 1607 | /> |
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"; |
| 5 | 5 | const testState = vi.hoisted(() => ({ |
| 6 | 6 | resources: [] as Array<unknown>, |
| 7 | 7 | assetState: "success" as "success" | "loading" | "failure", |
| 8 | imageDimensions: undefined as { width: number; height: number } | undefined, | |
| 8 | 9 | })); |
| 9 | 10 | |
| 10 | 11 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| ... | ... | @@ -14,7 +15,11 @@ vi.mock("../assets/assetUrls", () => ({ |
| 14 | 15 | testState.resources.push(resource); |
| 15 | 16 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| 16 | 17 | 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 | }; | |
| 18 | 23 | }, |
| 19 | 24 | })); |
| 20 | 25 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| ... | ... | @@ -42,7 +47,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ |
| 42 | 47 | useOpenChangeRequestLink: () => vi.fn(), |
| 43 | 48 | })); |
| 44 | 49 | |
| 45 | import ChatMarkdown from "./ChatMarkdown"; | |
| 50 | import ChatMarkdown, { ChatMarkdownAssetImage } from "./ChatMarkdown"; | |
| 46 | 51 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; |
| 47 | 52 | |
| 48 | 53 | const threadRef = { |
| ... | ... | @@ -92,6 +97,7 @@ describe("ChatMarkdown workspace images", () => { |
| 92 | 97 | beforeEach(() => { |
| 93 | 98 | testState.resources = []; |
| 94 | 99 | testState.assetState = "success"; |
| 100 | testState.imageDimensions = undefined; | |
| 95 | 101 | }); |
| 96 | 102 | |
| 97 | 103 | it.each([ |
| ... | ... | @@ -245,6 +251,42 @@ describe("ChatMarkdown workspace images", () => { |
| 245 | 251 | expect(html).toContain('<span id="user-content-diagram"'); |
| 246 | 252 | }); |
| 247 | 253 | |
| 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("")); | |
| 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 | ||
| 248 | 290 | it("reserves a slot for an image that is alone in a list item", () => { |
| 249 | 291 | expect(render("- ")).toContain("aspect-video"); |
| 250 | 292 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | ... | @@ -3342,7 +3342,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3342 | 3342 | alt={viewedImage.alt} |
| 3343 | 3343 | srcFragment={viewedImage.srcFragment} |
| 3344 | 3344 | workspaceRoot={workspaceRoot} |
| 3345 | style={{ maxHeight: "16rem" }} | |
| 3345 | maxHeightRem={16} | |
| 3346 | 3346 | onImageExpand={onImageExpand} |
| 3347 | 3347 | /> |
| 3348 | 3348 | </div> |