fix(web): keep chat media at a stable size while it loads - #9938
Conversation
Assistant markdown images collapsed to zero height between the signed URL resolving and the bytes arriving, then jumped to their natural size, and failures swapped to a one-line chip. Remote images had no loading or error state at all. Every image in a thread moved the rows below it two or three times, right as the user scrolled to it because lazy loading deferred the fetch until the row was in view. Both image paths now share one component that reserves a 16:9 slot (or the authored size) through URL resolution and byte loading, hosts the image invisibly inside it until it decodes, and shows the failure state in the same slot. Images decode async instead of lazy so buffered rows settle before they scroll in. The video failure panel takes the same 16:9 slot as its loading and playing states. Co-Authored-By: Claude Code <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Bugbot Autofix is ON, but a cloud agent failed to start.
Reviewed by Cursor Bugbot for commit fe104b4. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This changes the default rendering lifecycle and layout of existing chat images, workspace media, and shared video failure states, with substantial new loader and Markdown-tree logic. The production-wide behavior change and new state management merit human review. You can add or adjust custom eligibility rules. Learn more. |
Review found three problems with the first pass. The wrapper span stayed around the decoded image, so its hit area and centering no longer matched the picture. Every image reserved a full-width slot, so a badge row stacked one per line and then collapsed, moving the page more than the badges do. And a failure never cleared the loaded flag, so the next URL rendered bare before it decoded. Images that are the only content of their block (optionally inside a link) are now marked standalone in the rehype pass and get the slot; images that share a line stay inline at their natural size. Once decoded the image renders bare, and a failure resets the loaded flag so recovery loads behind the slot again. The video failure panel is capped at its container height so it cannot overflow the file preview pane. Co-Authored-By: Claude Code <noreply@anthropic.com>
A link is inline content, so an image that is the only child of a link is standalone only when the link is the only child of its block. Co-Authored-By: Claude Code <noreply@anthropic.com>
Track which URL decoded instead of a bare flag. A null src while the asset URL re-resolves keeps showing the last decoded image, a new URL for the same file replaces it in place, and a failure forgets it so the next attempt loads behind the slot. Image components are keyed on the file identity so a different file starts from the slot. Co-Authored-By: Claude Code <noreply@anthropic.com>
Walk down from a block (paragraph, list item, cell, root) through inline wrappers to find a sole image, instead of treating any element with one child as a block. Emphasis around an image beside text no longer earns a slot. Co-Authored-By: Claude Code <noreply@anthropic.com>
The direct-image path used to spread the sanitized props onto its img; keep doing that so an authored id or align survives, and put the id on the loading slot too so a fragment link resolves before the image does. Co-Authored-By: Claude Code <noreply@anthropic.com>
## What's Changed * chore: configure Knip workspace audits by @juliusmarminge in pingdotgg/t3code#9958 * refactor(web): prune unused UI and provider code by @juliusmarminge in pingdotgg/t3code#9959 * chore(mobile): remove obsolete widget wiring script by @juliusmarminge in pingdotgg/t3code#9960 * chore: remove redundant root tooling dependencies by @juliusmarminge in pingdotgg/t3code#9961 * ci: reject unused files and dependencies with Knip by @juliusmarminge in pingdotgg/t3code#9962 * test(contracts): keep driver default lookup private by @juliusmarminge in pingdotgg/t3code#9968 * test(server): remove Azure permissions constant snapshot by @juliusmarminge in pingdotgg/t3code#9973 * refactor(shared): remove unused viewport formatters by @juliusmarminge in pingdotgg/t3code#9970 * refactor(mobile): remove unused provider option summary by @juliusmarminge in pingdotgg/t3code#9971 * refactor(client-runtime): remove unused connection phase message by @juliusmarminge in pingdotgg/t3code#9972 * refactor(mobile): remove unused layout calculations by @juliusmarminge in pingdotgg/t3code#9974 * refactor(client-runtime): remove unused file position predicate by @juliusmarminge in pingdotgg/t3code#9976 * refactor(mobile): remove unused font size steppers by @juliusmarminge in pingdotgg/t3code#9975 * test(server): cover thread lookup through command invariants by @juliusmarminge in pingdotgg/t3code#9978 * test(server): remove provider equality wrapper fixture by @juliusmarminge in pingdotgg/t3code#9979 * test(server): assert the dispatched welcome thread model by @juliusmarminge in pingdotgg/t3code#9980 * refactor(desktop): remove unused keyring remediation text by @juliusmarminge in pingdotgg/t3code#9981 * refactor(desktop): remove test-only Electron error predicates by @juliusmarminge in pingdotgg/t3code#9982 * refactor(web): remove unused pull request state label by @juliusmarminge in pingdotgg/t3code#9984 * perf(web): keep timeline row reuse engaged while text streams by @juliusmarminge in pingdotgg/t3code#9909 * fix(web): reset markdown widgets when the previewed file changes by @juliusmarminge in pingdotgg/t3code#9910 * fix(mobile): keep highlighting review diffs after a long line by @juliusmarminge in pingdotgg/t3code#9911 * fix(marketing): align the endorsement carousel with its heading by @juliusmarminge in pingdotgg/t3code#9912 * fix(client): keep warm thread resumes live instead of flashing sync by @juliusmarminge in pingdotgg/t3code#9913 * test(server): remove authorization prompt snapshots by @juliusmarminge in pingdotgg/t3code#9985 * test(server): remove static OAuth page snapshots by @juliusmarminge in pingdotgg/t3code#9986 * test(server): remove provider label identity assertion by @juliusmarminge in pingdotgg/t3code#9987 * test(server): consolidate agent activity opt-in coverage by @juliusmarminge in pingdotgg/t3code#9988 * refactor(shared): remove unused preview URL predicate by @juliusmarminge in pingdotgg/t3code#9989 * refactor(shared): remove unused mention path serializer by @juliusmarminge in pingdotgg/t3code#9990 * refactor(shared): remove retired PATH capture parser by @juliusmarminge in pingdotgg/t3code#9991 * refactor(client-runtime): remove unused subagent selectors by @juliusmarminge in pingdotgg/t3code#9992 * refactor(web): test the live usage column builder by @juliusmarminge in pingdotgg/t3code#9993 * refactor(web): remove unused aspect ratio reconciler by @juliusmarminge in pingdotgg/t3code#9994 * refactor(web): remove obsolete cloud listing helpers by @juliusmarminge in pingdotgg/t3code#9995 * test(web): remove composer control style snapshots by @juliusmarminge in pingdotgg/t3code#9996 * test(web): keep the preview profile label helper private by @juliusmarminge in pingdotgg/t3code#9997 * test(server): cover raw OpenCode deltas through the adapter by @juliusmarminge in pingdotgg/t3code#9977 * refactor(web): remove obsolete pull request link opener by @juliusmarminge in pingdotgg/t3code#9983 * test(relay): keep the stage slug helper private by @juliusmarminge in pingdotgg/t3code#9998 * refactor(mobile): keep project selection helper private by @juliusmarminge in pingdotgg/t3code#9999 * refactor(mobile): keep review default ID helper private by @juliusmarminge in pingdotgg/t3code#10000 * refactor(mobile): remove unused native style constants by @juliusmarminge in pingdotgg/t3code#10001 * refactor(mobile): test terminal palettes through public theme API by @juliusmarminge in pingdotgg/t3code#10002 * refactor(mobile): remove unused file tree walkers by @juliusmarminge in pingdotgg/t3code#10003 * refactor(shared): keep persisted settings helpers private by @juliusmarminge in pingdotgg/t3code#10004 * test(mobile): remove mocked UUID shape assertions by @juliusmarminge in pingdotgg/t3code#10006 * refactor(mobile): test final connection status presentation by @juliusmarminge in pingdotgg/t3code#10007 * test(web): keep pull request menu items private by @juliusmarminge in pingdotgg/t3code#10016 * refactor(shared): test favicon selection through public API by @juliusmarminge in pingdotgg/t3code#10005 * refactor(server): remove test-only pricing normalizer by @juliusmarminge in pingdotgg/t3code#10017 * refactor(web): remove unused desktop update visibility helper by @juliusmarminge in pingdotgg/t3code#10014 * refactor(web): remove obsolete provider update helpers by @juliusmarminge in pingdotgg/t3code#10015 * refactor(web): remove unused terminal context preview formatter by @juliusmarminge in pingdotgg/t3code#10009 * refactor(web): test environment-scoped draft promotion by @juliusmarminge in pingdotgg/t3code#10010 * fix(web): retain wrapped row heights during edits by @juliusmarminge in pingdotgg/t3code#10018 * refactor(shared): remove unused Clerk hostname predicate by @juliusmarminge in pingdotgg/t3code#10008 * refactor(tailscale): keep package internals private by @juliusmarminge in pingdotgg/t3code#10011 * ci: reject unused tailscale exports with Knip by @juliusmarminge in pingdotgg/t3code#10012 * fix(web): keep chat media at a stable size while it loads by @juliusmarminge in pingdotgg/t3code#9938 * refactor(server): keep manifest age parsing private by @juliusmarminge in pingdotgg/t3code#10028 * refactor(mobile): remove unused awareness relay URL normalizer by @juliusmarminge in pingdotgg/t3code#10029 * refactor(server): remove unused startup heartbeat launcher by @juliusmarminge in pingdotgg/t3code#10030 * refactor(shared): keep search ranking comparator private by @juliusmarminge in pingdotgg/t3code#10031 * refactor(server): keep telemetry identity errors private by @juliusmarminge in pingdotgg/t3code#10032 * refactor(mobile): test composer persistence through the live decoder by @juliusmarminge in pingdotgg/t3code#10033 * refactor(web): remove unused sidebar selectors by @juliusmarminge in pingdotgg/t3code#10034 * refactor(server): keep Cursor fallback models private by @juliusmarminge in pingdotgg/t3code#10038 * refactor(web): remove unused xterm link range helpers by @juliusmarminge in pingdotgg/t3code#10040 * refactor(mobile): remove obsolete review list builder by @juliusmarminge in pingdotgg/t3code#10039 * test(server): remove duplicate VCS error constructor checks by @juliusmarminge in pingdotgg/t3code#10042 * refactor(mobile): keep appearance calculations private by @juliusmarminge in pingdotgg/t3code#10043 * refactor(web): remove unused sidebar menu action by @juliusmarminge in pingdotgg/t3code#10044 * refactor(web): test live Ghostty link resolution directly by @juliusmarminge in pingdotgg/t3code#10041 * test(server): exercise Codex prompts through public assembly by @juliusmarminge in pingdotgg/t3code#10045 * refactor(shared): remove unused elapsed-time adapter by @juliusmarminge in pingdotgg/t3code#10046 * refactor(web): remove unused preview thread reset helper by @juliusmarminge in pingdotgg/t3code#10049 * refactor(desktop): remove test-only error predicates by @juliusmarminge in pingdotgg/t3code#10047 * refactor(mobile): keep review reset hashing private by @juliusmarminge in pingdotgg/t3code#10048 * test(web): remove AppRoot element order snapshot by @juliusmarminge in pingdotgg/t3code#10052 * refactor(codex): keep app-server client internals private by @juliusmarminge in pingdotgg/t3code#10035 * ci: reject unused Codex client exports with Knip by @juliusmarminge in pingdotgg/t3code#10036 * refactor(server): simplify native telemetry error internals by @juliusmarminge in pingdotgg/t3code#10057 * refactor(mobile): remove write-only terminal font cache by @juliusmarminge in pingdotgg/t3code#10058 * refactor(web): remove obsolete HSL theme generator by @juliusmarminge in pingdotgg/t3code#10061 * refactor(mobile): remove obsolete native diff token stream by @juliusmarminge in pingdotgg/t3code#10062 * test(server): remove title prompt editorial snapshots by @juliusmarminge in pingdotgg/t3code#10063 * test(server): remove repeated runtime prompt interpolation cases by @juliusmarminge in pingdotgg/t3code#10059 * refactor(web): observe preview tests through the live registry by @juliusmarminge in pingdotgg/t3code#10064 * test(server): remove keybinding default assignment snapshot by @juliusmarminge in pingdotgg/t3code#10065 * refactor(mobile): remove obsolete whole-file review highlighters by @juliusmarminge in pingdotgg/t3code#10067 * test(server): cover CLI runner detection through command suggestions by @juliusmarminge in pingdotgg/t3code#10066 * refactor(mobile): remove unused cloud relay URL normalizer by @juliusmarminge in pingdotgg/t3code#10068 * refactor(web): test live keybinding resolvers directly by @juliusmarminge in pingdotgg/t3code#10069 * test(server): cover Grok skill parsing through discovery by @juliusmarminge in pingdotgg/t3code#10070 * test(web): remove mocked diff view prop snapshot by @juliusmarminge in pingdotgg/t3code#10073 * test(web): remove mocked annotation options snapshot by @juliusmarminge in pingdotgg/t3code#10074 * refactor(web): keep pending action labels private by @juliusmarminge in pingdotgg/t3code#10075 * refactor(mobile): remove unused cloud pending-status mapper by @juliusmarminge in pingdotgg/t3code#10071 * refactor(web): remove unused model picker hint helpers by @juliusmarminge in pingdotgg/t3code#10072 * fix(server): resume checkpointing after git init by @Yash-Singh1 in pingdotgg/t3code#10078 * feat(web): first-run welcome wizard with agent setup and project import by @t3dotgg in pingdotgg/t3code#5362 **Full Changelog**: pingdotgg/t3code@v0.0.39-nightly.20260905.1285...v0.0.39-nightly.20260905.1286 Upstream release: https://github.com/pingdotgg/t3code/releases/tag/v0.0.39-nightly.20260905.1286

Problem
Assistant markdown images in the chat timeline moved the rows below them two or three times while loading. Between the signed asset URL resolving and the bytes arriving, the
<img>was zero height (thew-64 aspect-videoplaceholder only covered URL resolution), then it jumped to its natural size, and a failure swapped to a one-line chip. Remotehttps://images had no loading or error state at all. Both usedloading="lazy", which, since LegendList already virtualizes, deferred the fetch until the row was in the viewport, so the jump landed exactly where the user was looking rather than in the offscreen mount buffer. The video failure panel wasmin-h-28while its loading and playing states were 16:9.Fix
width/height) through URL resolution and byte loading, host the<img>invisibly inside it untilonLoad, and show the failure state in the same slot. Once decoded the image renders bare again, so its box, hit area, and alignment are exactly its own.decoding="async"instead ofloading="lazy", so rows settle in LegendList's mount buffer before they scroll into view.MediaVideoPlayerfailure panel takes the sameaspect-videoslot as its loading and playing states, capped atmax-h-fullso it cannot overflow the file preview pane.Mobile's
ThreadMarkdownImagealready does fixed-frame-then-measure, so no change there.Evidence
Cold load of a seeded thread (3 badges, a linked figure beside text, landscape/portrait/wide workspace screenshots, a remote image, a missing file, a 404 remote image, a missing video) with image responses delayed 4 s, measured with a
layout-shiftPerformanceObserver armed before the timeline mounted.The one remaining entry is the portrait screenshot growing past its 16:9 slot — the single shift the design accepts.
While images load — before: every image is a zero-height gap; after: standalone images hold their slot, the badge row and linked figure stay inline.
Settled — badges and the linked figure are inline in both; failure states for a missing workspace file, a 404 remote image (previously a broken-image glyph), and a missing video now share the media slot.
Recording — side by side, same throttling.
https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/968fc5e291327df4/t3-media-side-by-side.mp4
Verification
vp test runonChatMarkdown.workspace-images.test.tsx(29 passed),ChatMarkdown.test.tsx,MessagesTimeline.test.tsx. New cases: slot classes identical across URL-loading, byte-loading, and failure; a standalone remote image gets the slot; two images in one paragraph stay inline; an image wrapped in a link or emphasis beside text stays inline; an image alone in a list item or a link gets the slot.vp lintandvp run typecheckclean forapps/web.<img>is a direct child of its<p>/<a>withvertical-align: middle, no wrapper. A delayed URL swap on a decoded image held its box at 271 px for 272 consecutive frames before the new bytes decoded in place.Model: Claude Fable 5 · Harness: Claude Code in T3 Code
🤖 Generated with Claude Code
Note
Reserve stable 16:9 frames for
ChatMarkdownimages andMediaVideoPlayerdataStandaloneattribute.ChatMarkdownImageloader that reserves a 16:9 frame while loading or on failure.ChatMarkdownAssetImagethrough the shared loader so they use the same load-aware lifecycle.Macroscope summarized 6d6472c.
Note
Low Risk
UI-only markdown media rendering and layout; no auth, data, or API contract changes beyond allowed
dataStandaloneon sanitized images.Overview
Reduces layout shift in chat markdown by treating figure-like images differently from inline badges/icons.
A rehype pass tags images as
dataStandalonewhen they are the only meaningful content in a block (paragraph, list item, cell, etc.), including when wrapped in a link or emphasis. Standalone images go through newChatMarkdownImage, which holds a 16:9 frame (or authored width/height) while the signed URL resolves, bytes decode, or load fails; the<img>loads invisibly inside the frame, then renders as a normal image once settled. Inline images in shared blocks skip the frame and keep natural sizing.ChatMarkdownAssetImageand direct/remote image rendering delegate toChatMarkdownImage, keyed on asset identity so re-signed URLs can swap without flashing;loading="lazy"is replaced withdecoding="async".MediaVideoPlayerfailure UI uses the same aspect-video slot as loading/playback. Tests cover standalone vs inline heuristics and stable frames across loading and failure.Reviewed by Cursor Bugbot for commit 6d6472c. Bugbot is set up for automated code reviews on this repo. Configure here.