Repository navigation
feat(ui): open images from their place in the message - #56896
Merged
Merged
Conversation
kitlangton
force-pushed
the
image-open-motion
branch
from
October 19, 2026 02:49
6d27d48 to
b5f8dbc
Compare
kitlangton
force-pushed
the
image-open-motion
branch
2 times, most recently
from
October 19, 2026 05:13
62fc7a3 to
6b36729
Compare
kitlangton
force-pushed
the
image-open-motion
branch
from
October 19, 2026 05:31
6b36729 to
b79bbbe
Compare
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Clicking an image in a session makes the preview appear at full size in one frame, with no link to the thumbnail you clicked, and closing makes it vanish. Claude's desktop app instead grows the image out of its spot in the message while the page dims, and shrinks it back on close, which keeps you oriented and feels much better.
What Changes
The image now flies between its place on the page and the preview, in both directions.
Clicking the open image doesn't close it: Claude doesn't either (its click toggles zoom). Double-click zoom, pinch, ⌘ + scroll, pan and the zoom controls from #56685 are unchanged.
Timing, measured from Claude
I measured the image rect frame by frame in a 60 fps screen recording of Claude's desktop preview, then fitted curves to it (progress 0 to 1, RMS error over every frame):
(.2,.8,.2,1)285 ms: 0.0094ease≈300 / ≈190 ms: 0.045 / 0.059A spring that starts from rest can't match Claude, because Claude's image starts fast. The same spring launched with an initial velocity fits as well as the best bezier on open and better on close, and one spring (0.2 s, bounce 0) covers both directions; only the launch differs. Claude starts moving ≈150 ms after the click; this branch starts as soon as the full image has decoded, 15 to 45 ms after the click in the recordings. The image stays sharp mid-flight because the flight uses the full-resolution image.
Shared-Element Flight
A stand-in
<img>with the samesrcis laid out once at the preview image's fitted rect and moved with transform andclip-pathonly. A flight frame, in viewport pixels, is where the whole image would be drawn (always its own aspect ratio), which part of it shows, and the visible corner radius. Translate plus uniform scale moves the image and an inset clip crops it, so anobject-fit: coverthumbnail (message and composer attachments) flies without squashing.Each of those nine numbers rides its own
Springon the sharedframeLoop, and one job per preview writes the transform, clip and opacities straight to the DOM; no CSS transitions or WAAPI drive the flight. Because every edge moves on screen, corners and crops stay even mid-flight. Opening or closing retargets the springs and adds the launch velocity to whatever velocity they already have, which is what makes the mid-open reversal continuous. Once every edge is within a quarter pixel and the backdrop has settled, the real image takes over on the same pixels; on close, the thumbnail reappears under the stand-in when it lands.Callers pass the element the preview opens from as
origin: markdown and tool output images, message attachments and composer attachments all do. Two supporting changes make the close visible:useDialogLayer()gainssetExitDuration(ms), which the preview sets from its tuned close timing (360 ms by default).Motion Library and Motion Workspace
This PR no longer carries its own copy of the spring library. It uses
@opencode/ui/motionfrom #56917 (with its closed-form spring step) through the same public API:Spring,frameLoopandprefersReducedMotion. The zoom ease still usesmotion'sanimate().The flight's timing is tunable in #56935's Motion workspace, in an Image open section registered through
@opencode/ui/motion-tuning:Buttons: Open (the newest image in view in the session, or a sample thumbnail pinned bottom-left when there is none), Close, Open, close mid-flight, and presets Claude match and Calm (0.32 s / 0.26 s springs, launches 6 and 16, backdrop 0.45 s / 0.28 s, fade 0.2 s). Edits persist on the device and apply from the next open or close. The global speed and slow motion stretch the springs and divide the launch velocities by the same factor, so the curve keeps its shape. The dialog's exit hold follows the tuned values (1.8 × the longest close duration, 360 ms by default) instead of a fixed 320 ms, so slow-motion closes land before the dialog unmounts.
Shared registry changes, additive only:
motionTuning.number()(with an optionalunitand atimeof"duration"or"rate"that applies the global speed), and the panel shows a number knob'sunit. The buttons register fromcreateImagePreview()in session-ui, so no app screen files change.Demo
Left:
beta. Right: this branch. Headless Chromium on the newUI/ImagePreview/FromMessagestory (a fictional poster drawn on a canvas), with real Playwright input: click the inline image, ⌘ + scroll zoom, Esc; click a cropped 58×46 thumbnail, click the backdrop; Esc 120 ms into an open; a plain open and close. Each action is synced on its click or key press.split-1x.mp4
The same moments at 0.25×. The right side is recorded with the page clock slowed to 0.25, so every frame is real;
betahas no animation, so its 1× recording is stretched.split-0.25x.mp4
Scope
Desktop and web image preview, plus its Image open section in the Motion workspace. The open waits for the full image to load and decode before anything appears; it uses the same URL as the thumbnail, so it's normally cached. Diff on top of #56935: 13 files, +838 / −24.
Verification
image-flight.test.tscoversobject-fitcover and contain geometry, the transform and clip onto uncropped and cropped thumbnails, a zoomed view clipped to the viewport, the spring flight following Claude's measured open (25%, 61% and 91% after 1, 3 and 7 frames, no overshoot), and a mid-flight turn-around that starts exactly where the image is and carries its outward velocity. The zoom tests and the library's own tests (pure and 17 DOM) pass on the optimized library.ui,session-uiandapp, andlint:changedreports 0 problems.FromMessagestory): no frame shows the stand-in together with the real image, open and close hand-offs land on exactly the real image's and thumbnail's rects, the mid-open Esc turns around continuously, and with slow motion on the flight takes about 4× as long and still lands before the dialog unmounts.