PR #4901
Sections
Review

feat: inspect image previews at actual size ↗

main ← feature/inspect-preview-imag-7ew 18 files +882 −21 PR #4901 ↗

Every shared image preview now opens in Fit and adds an Actual size mode that shows the image at its intrinsic pixels inside a scrollable, viewport-bounded region. The same viewer serves sent attachments, rich-output image cards, and composer images.

Why this PR exists

The shared preview always scaled the image down to fit the dialog. A large screenshot became too small to read, and the viewer had no way to inspect fine detail.

Who is affected: Operators who review screenshots, diagrams, and generated images in task, Office, and composer previews.

Result: The preview starts fitted and can show the image at its real pixel size. The image scrolls in both axes while the Fit, Actual size, and Close controls stay fixed.

What it does

Impact at a glance

User experience

Entry pointBeforeAfterSource
Task, Office, and composer image previewThe image always fit the dialog and the viewer had no mode controls.The preview opens in Fit with Fit and Actual size buttons. Actual size shows intrinsic pixels and scrolls in both axes.apps/web/components/task/chat/image-preview-dialog.tsx
Composer image chipThe controlled dialog depended on default focus behavior after close.Closing returns focus to the image chip, and each opening starts in Fit.apps/web/components/task/chat/context-items/image-item.tsx

Feature flags and rollout

The inspection mode ships unconditionally in the shared web viewer. No runtime flag or profile setting gates it.

No UX changes with flags off

No flag exists, so there is no disabled path. The viewer change applies to every user after deployment.

Architecture, end to end

Image sources feed the shared dialog or the composer dialog. Both mount ImagePreviewContent, which holds the mode state and renders one scrollable canvas with fixed controls.

flowchart LR
  S[Attachment, rich-output, or composer source] --> D[ImagePreviewDialog or composer Dialog]
  D --> C[ImagePreviewContent keyed by src]
  C --> M[ImageInspection mode state]
  M --> R[Scrollable canvas region]
  M --> B[Fit and Actual size buttons]
  R --> I[Image at fitted or intrinsic size]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

export function ImagePreviewContent({ src, alt }: ImagePreviewContentProps)
Click for details →

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>
    </>
  );
}
export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextItem })
Click for details →

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}
triggerRef?: Ref<HTMLButtonElement>;
Click for details →

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}
export const IMAGE_PREVIEW_DIALOG_CONTENT_CLASSNAME
Click for details →

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))]";
export async function expectActualGeometry(dialog: Locator)
Click for details →

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;
}
Read the changes as a list

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;
}

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • The change is limited to the shared web image viewer. It adds no backend, schema, or protocol change.
  • New component and browser tests cover mode switching, source replacement, focus return, and containment.
  • A scroll or focus regression affects only image previews, and the change rolls back with the web bundle.

Trade-offs and review notes

Where to look first

  1. Check the keyed remount and mode reset in image-preview-dialog.tsx.
  2. Check the composer focus return in image-item.tsx and the triggerRef pass-through in context-chip.tsx.
  3. Check the mobile full-height and safe-area classes and the touch target sizes.
  4. Check that the browser tests use a real large image and assert measured geometry.