Shared fit and actual-size viewer
apps/web/components/task/chat/image-preview-dialog.tsx ↗The content keys a new ImageInspection by source, holds the mode state, and switches the canvas between overflow hidden and overflow auto while resetting the scroll position.
New inspection content
export function ImagePreviewContent({ src, alt }: ImagePreviewContentProps) {
return <ImageInspection key={src} src={src} alt={alt} />;
}
function ImageInspection({ src, alt }: ImagePreviewContentProps) {
const { t } = useTranslation();
const [actualSize, setActualSize] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const changeMode = (actual: boolean) => {
if (canvasRef.current) {
canvasRef.current.scrollLeft = 0;
canvasRef.current.scrollTop = 0;
}
setActualSize(actual);
};
return (
<>
<div className="flex shrink-0 items-center justify-between">
<DialogTitle className="sr-only">{t("task:imagePreview")}</DialogTitle>
<span aria-hidden />
<DialogClose asChild>
<Button
variant="ghost"
size="icon-sm"
className="max-md:size-12 [@media(pointer:coarse)]:size-12"
aria-label={t("common:close")}
>
<IconX aria-hidden />
</Button>
</DialogClose>
</div>
<div
ref={canvasRef}
role="region"
aria-label={t("task:questionImageCanvas")}
tabIndex={0}
className={cn(
"min-h-0 flex-1 overscroll-contain rounded-md bg-muted/40",
actualSize ? "overflow-auto" : "overflow-hidden",
)}
>
<div
className={cn(
"flex min-h-full min-w-full",
!actualSize && "h-full items-center justify-center",
)}
>
<img
src={src}
alt={alt}
draggable={false}
className={cn(
"rounded",
actualSize
? "h-auto max-w-none shrink-0 self-start"
: "max-h-full max-w-full object-contain",
)}
/>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant={actualSize ? "outline" : "secondary"}
className="h-7 max-md:h-12 max-md:min-w-12 [@media(pointer:coarse)]:h-12 [@media(pointer:coarse)]:min-w-12"
aria-pressed={!actualSize}
onClick={() => changeMode(false)}
>
{t("task:questionImageFit")}
</Button>
<Button
type="button"
variant={actualSize ? "secondary" : "outline"}
className="h-7 max-md:h-12 max-md:min-w-12 [@media(pointer:coarse)]:h-12 [@media(pointer:coarse)]:min-w-12"
aria-pressed={actualSize}
onClick={() => changeMode(true)}
>
{t("task:questionImageActualSize")}
</Button>
</div>
</>
);
}
Composer chip focus return
apps/web/components/task/chat/context-items/image-item.tsx ↗The composer dialog has no DialogTrigger, so the image item keeps a ref to the chip button and restores focus to it when the dialog closes.
Ref and close handling
import { memo, useState, useCallback, type ReactNode } from "react";
import { memo, useState, useCallback, useRef, type ReactNode } from "react";
const { isFinePointer } = useResponsiveBreakpoint();
const [dialogOpen, setDialogOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement>(null);
preview={preview}
onClick={previewSrc ? handleClick : undefined}
onRemove={item.onRemove}
triggerRef={chipRef}
/>
<DialogContent
aria-describedby={undefined}
showCloseButton={false}
onCloseAutoFocus={(event) => {
const opener = chipRef.current;
if (opener) {
event.preventDefault();
opener.focus();
}
}}
className={IMAGE_PREVIEW_DIALOG_CONTENT_CLASSNAME}
Chip trigger ref pass-through
apps/web/components/task/chat/context-items/context-chip.tsx ↗ContextChip accepts a ref for its main button and attaches it on both the hover-preview button and the plain onClick button, without wrapping the composer context row.
Prop and ref attachment
import { memo, useRef, useState, type ReactNode } from "react";
import { memo, useRef, useState, type ReactNode, type Ref } from "react";
dataIsDirectory?: boolean;
onClick?: () => void;
triggerRef?: Ref<HTMLButtonElement>;
onUnpin?: () => void;
dataIsDirectory,
onClick,
triggerRef,
onUnpin,
<button
type="button"
ref={triggerRef}
onClick={usesTouchDrawer ? undefined : onClick}
labelElement = (
<button
ref={triggerRef}
className={`flex ${labelSizingClass} min-w-0 flex-1 items-center gap-1 text-left`}
onClick={onClick}
Bounded dialog geometry
apps/web/components/task/chat/image-preview-dialog.tsx ↗The dialog becomes a fixed column with a desktop height cap and a full-height phone layout with safe-area padding, so the controls stay outside the scroll region.
Dialog class
export const IMAGE_PREVIEW_DIALOG_CONTENT_CLASSNAME =
"flex min-h-16 w-[min(96vw,1126px)] max-w-[calc(100vw-1rem)] items-center justify-center overflow-hidden p-2 sm:max-w-[calc(100vw-2rem)] sm:p-3";
const IMAGE_PREVIEW_IMAGE_CLASSNAME =
"block h-auto max-h-[calc(100dvh-5rem)] w-[min(92vw,1100px)] max-w-full rounded object-contain";
"flex h-[min(90dvh,900px)] w-[min(96vw,1126px)] max-w-[calc(100vw-1rem)] flex-col gap-2 overflow-hidden p-2 sm:max-w-[calc(100vw-2rem)] sm:p-3 max-md:h-dvh max-md:w-screen max-md:max-w-none max-md:rounded-none max-md:pt-[max(0.5rem,env(safe-area-inset-top))] max-md:pb-[max(0.5rem,env(safe-area-inset-bottom))]";
Rendered geometry browser check
apps/web/e2e/tests/chat/image-inspection-helpers.ts ↗The helper compares rendered width and height with the image natural size and asserts positive overflow on both axes, so the tests prove real scrolling instead of CSS classes.
Geometry assertion
export async function expectActualGeometry(dialog: Locator) {
const image = dialog.getByRole("img");
const canvas = dialog.getByRole("region", { name: "Image inspection" });
const size = await image.evaluate((el: HTMLImageElement) => ({
width: el.getBoundingClientRect().width,
height: el.getBoundingClientRect().height,
naturalWidth: el.naturalWidth,
naturalHeight: el.naturalHeight,
}));
expect(size.width).toBeCloseTo(size.naturalWidth, 0);
expect(size.height).toBeCloseTo(size.naturalHeight, 0);
const overflow = await canvas.evaluate((el) => ({
x: el.scrollWidth - el.clientWidth,
y: el.scrollHeight - el.clientHeight,
}));
expect(overflow.x).toBeGreaterThan(0);
expect(overflow.y).toBeGreaterThan(0);
return canvas;
}