← reports

#1670 · HEIC images aren't rendered in the UI

Bug Low Effort: Medium ui open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none

TL;DR

When you paste (or attach) a .heic photo — the default format iPhones shoot in — into the bb composer, the thumbnail in the composer and, after sending, the image in the chat history both show as a broken-image icon with the file name next to it (exactly the reporter's screenshot). PNG/JPEG pasted the same way render fine.

The mechanism is simple: bb uploads the file bytes untouched (POST /api/v1/projects/:id/attachments), classifies anything whose browser MIME type starts with image/ as a localImage, and later serves the very same bytes back as content-type: image/heic from GET …/attachments/content. The web app and the Electron desktop app render that URL in a plain <img>. Chromium has no HEIC/HEIF decoder (only Safari on Apple platforms decodes HEIC natively), so the <img> fires error and naturalWidth stays 0. There is no transcoding step anywhere in bb — not on upload, not on serve, not in the client — so nothing ever turns the HEIC into something the renderer can draw.

Deeper than the UI symptom: the model does not see the picture either. In my repro the Codex provider staged the HEIC into the thread's Attachments dir and Codex sent "image content omitted because it could not be processed" in place of the image. So HEIC attachments are silently useless end to end, which argues for fixing this once at the upload boundary (transcode to JPEG/PNG when storing) rather than only in the renderer.

Claims vs findings

ClaimStatusEvidence
jpg and png images pasted into chat render wellVerifiedControl: the same picture as PNG pasted into the follow-up composer renders (naturalWidth 480), see 1670-control-png.png and step5.out.
HEIC images pasted into chat show up broken in the chat historyVerifiedReal HEIC (nokia sample, ftyp mif1/heic) pasted through the composer's handlePaste path, sent to a codex thread; the timeline <img> for it has naturalWidth 0 and shows the broken-image glyph + filename (1670-timeline-broken.png, step4.out). Same in the composer strip before sending (1670-composer-broken.png).
(implicit) it is a rendering problem in "the UI"Verified, but narrowerThe UI is a plain <img src=…/attachments/content?path=x.heic>; the failure is Chromium's missing HEIC decoder plus bb never transcoding. In Safari (WebKit on macOS/iOS, which decodes HEIC natively) the same URL would probably render — not tested here (no Apple hardware).
(implicit) only the display is affectedRefutedCodex received image content omitted because it could not be processed for the staged .heic (codex-rollout-heic-excerpt.txt). Claude Code gets a [Attached image … use the Read tool] marker pointing at a .heic file, which its Read tool does not decode as an image either (inferred from the bridge code, not run).

Environment

Minimal reproduction

Precondition: build the worktree, start your dev instance (pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build && scripts/bb-dev-app current) and note the App/Server URLs; the scripts below hardcode :12237/:20237 and the project id — replace with yours. dev-browser (Playwright CLI) is used for the browser steps; each script is under 1670/repro/.

  1. Create a project on the local host (from bb machine list):
    $ curl -s -X POST http://localhost:20237/api/v1/projects -H 'content-type: application/json' \
       -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/1670-qa","hostId":"host_tt6rxgwxvk"}}'
    {"id":"proj_2yvd4nxtww", ...}
  2. Upload the HEIC through the same API the composer uses and fetch it back. Expected: an image the browser can draw. Actual: the untouched HEIC bytes with content-type: image/heic (upload-heic.json, content-headers.txt):
    $ curl -s -X POST http://localhost:20237/api/v1/projects/proj_2yvd4nxtww/attachments -F "file=@sample.heic;type=image/heic"
    {"type":"localImage","path":"sample-1787040358944-bkl621.heic","name":"sample.heic","mimeType":"image/heic","sizeBytes":293608}
    $ curl -s -o /dev/null -D - "http://localhost:20237/api/v1/projects/proj_2yvd4nxtww/attachments/content?path=sample-1787040358944-bkl621.heic"
    HTTP/1.1 200 OK
    content-type: image/heic
    content-length: 293608
  3. Prove the renderer cannot decode it (step1-open-and-decode.js): open the app, fetch that URL into an Image. Actual (step1.out):
    $ dev-browser --headless run step1-open-and-decode.js
    http://localhost:12237/ bb
    {"status":200,"type":"image/heic","size":293608,"imgEvent":"error","naturalWidth":0,"ua":"… HeadlessChrome/145.0.7632.6 …"}
  4. Paste the HEIC into the new-thread composer exactly the way a user does — a paste event whose clipboardData carries a File of type image/heic — (step3-paste-heic.js). The composer's handlePaste takes the file branch (defaultPrevented: true), uploads it and shows the thumbnail. Actual (step3.out): thumbnail is broken.
    $ dev-browser --headless run step3-paste-heic.js
    paste: {"fileType":"image/heic","size":293608,"defaultPrevented":true}
    attachment imgs: [ { "src": "http://localhost:12237/api/v1/projects/proj_2yvd4nxtww/attachments/content?path=IMG_0001-1787040476053-qgrrgw.heic",
        "alt": "IMG_0001.heic", "complete": true, "naturalWidth": 0, "naturalHeight": 0 } ]
    composer before paste
    Before: empty new-thread composer (project qa).
    composer after HEIC paste shows broken image
    The moment the bug shows: after the paste the attachment strip has a broken-image glyph with "IMG_0001.he…" instead of a thumbnail.
  5. Type Reply only with ok., press Enter (step4-send.js). The thread is created, Codex answers ok, and the user message in the chat history shows the broken image (step4.out):
    url after submit: http://localhost:12237/projects/proj_2yvd4nxtww/threads/thr_csyg3pfycr
    timeline attachment imgs: [ { "src": "…/attachments/content?path=IMG_0001-1787040476053-qgrrgw.heic",
        "alt": "IMG_0001-1787040476053-qgrrgw.heic", "complete": true, "naturalWidth": 0, "naturalHeight": 0 } ]
    prompt typed next to broken heic thumbnail
    Triggering: prompt typed, HEIC still attached, about to press Enter.
    chat history shows broken image for heic
    Chat history: the user bubble (top right) renders the HEIC as a broken image with its stored filename — the reporter's screenshot.
  6. Control (step5-control-png.js): paste the same picture as PNG into the follow-up composer. It renders (naturalWidth 480), while the HEIC in the history above stays broken:
    PNG thumbnail renders while HEIC in history is broken
    Bottom: PNG thumbnail (autumn cabin) renders in the composer. Top: HEIC bubble still broken in the same view.
  7. What the model got (codex-rollout-heic-excerpt.txt, from ~/.codex/sessions/… for this turn):
    {"type":"input_text","text":"<image name=[Image #1] path=\"…/thread-storage/thr_csyg3pfycr/Attachments/IMG_0001-1787040476053-qgrrgw.heic\">"},
    {"type":"input_text","text":"image content omitted because it could not be processed"},
    {"type":"input_text","text":"</image>"}

Unit-level repro (fails on main)

1670/repro/repro-1670-heic-attachment.test.ts (lives at apps/server/test/public/repro-1670-heic-attachment.test.ts in my worktree; uses the real Hono app + in-memory SQLite via withTestHarness). It uploads a real HEIC as image/heic, asserts the server classifies it as localImage (it does — that is why the UI puts it in an <img>), then asserts the content endpoint serves a Chromium-decodable type. The last assertion fails on 16ceb3a54 with localImage attachment served as image/heic. Run: cd apps/server && pnpm exec vitest run test/public/repro-1670-heic-attachment.test.ts (set BB_1670_HEIC=/path/to/sample.heic if the sample is elsewhere; it falls back to a minimal HEIC ftyp box). Output: repro-test.out.

FAIL  test/public/repro-1670-heic-attachment.test.ts > repro #1670: HEIC prompt attachment > serves an uploaded HEIC localImage in a Chromium-decodable format
AssertionError: localImage attachment served as image/heic, which Chromium cannot decode: expected false to be true
// Repro for get-bb/bb#1670: HEIC images pasted into the composer are stored and
// served verbatim as image/heic. Chromium (web app + Electron desktop) has no
// HEIC decoder, so the <img> that the composer / timeline renders from
// GET /api/v1/projects/:id/attachments/content is broken.
//
// This test encodes the contract a fix needs: an uploaded image attachment
// classified as `localImage` must be served in a format Chromium can decode.
// It FAILS on main (16ceb3a54) because the served content-type is image/heic.
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { uploadedPromptAttachmentSchema } from "@bb/server-contract";
import { readJson } from "../helpers/json.js";
import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

// Formats Chromium's image decoder handles (see also the list in
// apps/app/src/components/secondary-panel/git-diff/useDiffFileContentsRequester.ts).
const CHROMIUM_DECODABLE_IMAGE_TYPES = new Set([
  "image/avif",
  "image/bmp",
  "image/gif",
  "image/jpeg",
  "image/png",
  "image/svg+xml",
  "image/webp",
  "image/x-icon",
  "image/vnd.microsoft.icon",
]);

const SAMPLE_HEIC =
  process.env.BB_1670_HEIC ?? "/tmp/bb-reports/issues/1670/repro/sample.heic";

describe("repro #1670: HEIC prompt attachment", () => {
  it("serves an uploaded HEIC localImage in a Chromium-decodable format", async () => {
    await withTestHarness(async (harness) => {
      const { host } = seedHostSession(harness.deps, { id: "host-1670" });
      const { project } = seedProjectWithSource(harness.deps, {
        hostId: host.id,
      });

      // Real HEIC (ISO BMFF, ftyp mif1/heic). Falls back to a minimal ftyp box
      // if the sample file is not present so the test still runs anywhere.
      const bytes = await readFile(SAMPLE_HEIC).catch(
        () =>
          new Uint8Array([
            0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x69, 0x66,
            0x31, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x68, 0x65,
            0x69, 0x63,
          ]),
      );

      const form = new FormData();
      form.set(
        "file",
        new File([bytes], "IMG_0001.heic", { type: "image/heic" }),
      );
      const uploadResponse = await harness.app.request(
        `/api/v1/projects/${project.id}/attachments`,
        { body: form, method: "POST" },
      );
      expect(uploadResponse.status).toBe(201);
      const uploaded = uploadedPromptAttachmentSchema.parse(
        await readJson(uploadResponse),
      );
      // The server classifies it as an image (so the UI renders it in <img>) ...
      expect(uploaded.type).toBe("localImage");

      const contentResponse = await harness.app.request(
        `/api/v1/projects/${project.id}/attachments/content?path=${encodeURIComponent(uploaded.path)}`,
      );
      expect(contentResponse.status).toBe(200);
      const contentType = contentResponse.headers.get("content-type") ?? "";

      // ... but serves bytes the renderer cannot decode. On main this is
      // "image/heic" -> assertion fails.
      expect(
        CHROMIUM_DECODABLE_IMAGE_TYPES.has(contentType.split(";")[0] ?? ""),
        `localImage attachment served as ${contentType}, which Chromium cannot decode`,
      ).toBe(true);
    });
  });
});

Root cause

The whole path is "store bytes, serve bytes, put URL in <img>". Nothing knows or cares what codec the image uses; only the MIME prefix image/ is inspected.

  1. Composer paste → upload. PromptBoxInternal.tsx#L1731-L1743: handlePaste collects clipboardData.items of kind file and calls onAttachFiles; that goes through useComposerAttachmentUploadsuseUploadPromptAttachment (project-mutations.ts#L191-L204) → sdk.projects.attachments.upload. No client-side conversion.
  2. Server stores verbatim and classifies by MIME prefix. attachments.ts#L140-L169:
    const isImage = (file.type || "").startsWith("image/");
    …
    const bytes = Buffer.from(await file.arrayBuffer());
    await writeFile(outputPath, bytes);
    return { type: isImage ? "localImage" : "localFile", path: storedName, name: file.name, mimeType: file.type || undefined, … };
    Chromium reports image/heic for .heic files, so the attachment becomes a localImage — the UI will try to display it, not just link it.
  3. Server serves verbatim with the extension's MIME. attachments.ts#L172-L189 (mimeTypes.lookup("x.heic") = image/heic) and the route at projects.ts#L904-L917.
  4. UI renders the URL in a plain <img>. Composer strip: AttachmentPreview.tsx#L66-L75 (any localImage or image/* MIME is treated as displayable, L8-L13). Timeline: ConversationAttachments.tsx#L149-L157, src built by user-attachment-images.tsbuildProjectAttachmentContentUrl.
  5. Chromium cannot decode HEIC/HEIF (patent-encumbered HEVC; Blink ships no decoder; the Electron desktop app is Chromium too). Verified: imgEvent: "error", naturalWidth: 0 in step 3. So the <img> falls back to the broken-image glyph + alt (the filename), which is precisely what the screenshot in the issue shows. bb itself already "knows" HEIC is not previewable elsewhere: the diff file preview allow-list at useDiffFileContentsRequester.ts#L163-L172 excludes image/heic; the prompt attachment path just never checks.

Deeper issue. The same untouched HEIC is what the host daemon stages for the provider (prompt-attachments.ts) and what Codex is handed as localImage (session-params.ts#L612-L613). Codex could not process it either (step 7). Claude Code gets a text marker pointing at the file (bridge.ts#L2486-L2488) and its Read tool handles png/jpg/gif/webp, not HEIC. Anthropic's and OpenAI's APIs accept only JPEG/PNG/GIF/WebP. So a HEIC attachment is a no-op for the agent while looking to the user like it was sent — a worse failure than the broken thumbnail.

Proposed fix (first principles)

Fix it once, at the server upload boundary, so every surface (web, desktop, CLI/SDK uploads, providers) benefits: in storeAttachment (apps/server/src/services/projects/attachments.ts), when the incoming file is image/heic/image/heif (or has a .heic/.heif extension — the MIME can be empty on some platforms), decode it and store a JPEG (or PNG when the source has alpha) instead; return mimeType: "image/jpeg", the new .jpg stored name and the new sizeBytes, keeping name as the original for display. Apply the 10 MB image limit to the transcoded output as well as the input.

PR review

No linked open PRs.

Related issues

Appendix

Commands run

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
git checkout 16ceb3a54            # worktree had been created at origin/main a108fa7ef; relevant files identical
scripts/bb-dev-app current        # App :12237, Server :20237, daemon :28237
mkdir -p /tmp/1670-qa && cd /tmp/1670-qa && git init -q && echo hi > README.md && git add -A && git commit -qm init
BB_SERVER_URL=http://localhost:20237 node packages/scripts/dist/commands/run-cli.js machine list --json   # host_tt6rxgwxvk
curl -s -X POST http://localhost:20237/api/v1/projects -H 'content-type: application/json' -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/1670-qa","hostId":"host_tt6rxgwxvk"}}'
curl -sL -o sample.heic https://github.com/nokiatech/heif/raw/gh-pages/content/images/autumn_1440x960.heic ; file sample.heic   # ISO Media, HEIF Image
curl -s -X POST http://localhost:20237/api/v1/projects/proj_2yvd4nxtww/attachments -F "file=@sample.heic;type=image/heic"
curl -s -o /dev/null -D - "http://localhost:20237/api/v1/projects/proj_2yvd4nxtww/attachments/content?path=sample-1787040358944-bkl621.heic"
dev-browser --headless --idle-timeout 20m --timeout 60 run 1670/repro/step1-open-and-decode.js
dev-browser --headless run 1670/repro/step2-snapshot.js
dev-browser --headless run 1670/repro/step3-paste-heic.js
dev-browser --headless --timeout 150 run 1670/repro/step4-send.js
ffmpeg -i sample.heic -vf scale=480:-1 sample-decoded-small.png     # host-side decode for the control/expected image
curl -s -X POST http://localhost:20237/api/v1/projects/proj_2yvd4nxtww/attachments -F "file=@sample-decoded-small.png;type=image/png"
dev-browser --headless run 1670/repro/step5-control-png.js
grep -rl qgrrgw.heic ~/.codex/sessions | xargs grep -o '.\{300\}heic.\{200\}'
BB_SERVER_URL=http://localhost:20237 node packages/scripts/dist/commands/run-cli.js thread log thr_csyg3pfycr
cd apps/server && pnpm exec vitest run test/public/repro-1670-heic-attachment.test.ts
git fetch origin main && git log 16ceb3a54..origin/main --oneline -- apps/server/src/services/projects/attachments.ts apps/app/src/components/promptbox apps/app/src/components/thread/timeline/ConversationAttachments.tsx apps/app/src/lib/user-attachment-images.ts
pnpm dev:stop

Thread event (client/turn/requested) for the repro turn

{"type":"client/turn/requested","data":{"source":"spawn","initiator":"user","input":[{"type":"text","text":"Reply only with ok.","mentions":[]},{"type":"localImage","path":"IMG_0001-1787040476053-qgrrgw.heic"}],"target":{"kind":"thread-start"},…}}

Note the persisted localImage carries only path; the MIME type the UI/server act on is re-derived from the extension at serve time.

Files

Things checked and ruled out