fix(desktop): eliminate image-hover layout jump in messages#813
Conversation
wesbillman
left a comment
There was a problem hiding this comment.
Fix is sound. The diagnosis (nested wrapper divs + Radix Trigger asChild attribute cloning repainting inline flow on :hover) is well-reasoned, the controlled-open-state rewrite is the right call, and the header comment guards against reintroduction. Single-image hover/zoom/right-click/download parity is preserved (incl. right-click on the open lightbox).
One regression to address before merge (multi-image gallery), plus a small asymmetry to confirm — both inline.
Note: I couldn't run pnpm test in a fresh review checkout (no node_modules), but the existing markdown unit tests cover classifyChildren/isImageOnlyParagraph (unchanged here) and wouldn't catch a CSS/markup grid regression anyway — the gallery case needs a manual multi-image check regardless.
| {/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */} | ||
| <img | ||
| alt={alt} | ||
| className="mt-1 block max-h-64 max-w-sm cursor-pointer rounded-xl object-contain" |
There was a problem hiding this comment.
🔴 Multi-image gallery layout regression. This bakes mt-1 max-w-sm directly onto the bare <img>. That breaks the 2-up gallery grid path in the p handler (line 514):
grid ... grid-cols-2 ... [&_div]:mt-0 [&_div]:max-w-none
That [&_div]:mt-0 [&_div]:max-w-none selector exists specifically to strip the per-image wrapper div's mt-3/max-w-sm inside the grid (added in #316, when each tile was a <div className="mt-3 ... max-w-sm">). This PR removed that wrapper <div>, so the selector now targets div but the styles live on img — it no longer neutralizes anything. In a 2-up gallery each image keeps its max-w-sm cap (won't fill the cell) and gains an unwanted mt-1. The single-inline-image test plan doesn't exercise this path.
Fix options:
- change the grid selector (line 514) to also hit images:
[&_img]:mt-0 [&_img]:max-w-none, or - have
ImageBlockaccept a layout variant that dropsmt-1 max-w-smin the grid context.
There was a problem hiding this comment.
🤖 Addressed in 6bd5a44f: the gallery selector now resets the new [data-block-media] wrapper and inner img styles (mt, max-width, and width), so multi-image grid cells still fill correctly after removing the old wrapper div.
| </DialogPrimitive.Portal> | ||
| </DialogPrimitive.Root> | ||
| </ImageContextMenu> | ||
| <span data-block-media="" className="block"> |
There was a problem hiding this comment.
🟡 Nit — className="block" asymmetry. The image branch adds <span data-block-media="" className="block">, but the sibling video branch above keeps <span data-block-media=""> with no block. Probably intentional (the bare <img> needs block context here), but worth a one-line confirm it's deliberate and not also needed for video.
There was a problem hiding this comment.
🤖 Confirmed deliberate. The image branch needs className="block" on the media marker because ImageBlock intentionally renders a bare img with no wrapper. The video branch can keep its existing span because VideoPlayer owns its block layout.
Hovering an inline image in a message would shift the body text down ~1-2px relative to the username header, leaving a visibly larger gap after a short pause. The cause was the inline image wrapper: a `<DialogPrimitive.Trigger asChild>` cloned onto a wrapper `<div>` around the `<img>`, with an `ImageContextMenu` wrapper around that. The nested wrapper divs + Radix attribute cloning caused a layout reflow in the surrounding inline flow on `:hover` state changes. Replace the wrappers with a new `ImageBlock` component that renders the `<img>` bare (no wrapping div) and drives the lightbox via controlled `open` state instead of `Trigger asChild`. Right-click-to-download and click-to-zoom continue to work; the message body no longer shifts. Co-authored-by: Cursor <cursoragent@cursor.com>
* origin/main: chore(release): release version 0.3.11 (#865) fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843) feat(mobile): star channels (Slack-style favorites) (#863) feat: desktop-screenshot skill to stop agents uploading relay media to PRs (#862) feat(desktop): star channels (Slack-style favorites) (#860) fix(desktop): handle symlinked persona pack directories (#859) feat: channel muting for desktop and mobile (#838) feat(acp): default SPROUT_ACP_MEMORY to on (#854) fix(desktop): eliminate image-hover layout jump in messages (#813) chore(release): release version 0.3.10 (#849) fix(desktop): harden relay mesh connect p-tag (#834) fix(desktop): scroll activity panel to bottom on open (#848) Polish desktop profile menu interactions (#836) fix(desktop): outline thread hover targets (#845) fix(desktop): keep message actions hover-only (#844) fix(desktop): let inbox composer fill available width (#841) # Conflicts: # desktop/src/app/AppShell.tsx # desktop/src/features/workspaces/useWorkspaceInit.ts
* origin/main: (39 commits) docs: add VISION_MESH.md — the compute-commons vision (#867) fix(desktop): simplify profile popover header (#853) fix(desktop): remove thread comment hover outline (#861) feat(desktop): always show channel section search/add buttons (#856) chore(release): release version 0.3.11 (#865) fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843) feat(mobile): star channels (Slack-style favorites) (#863) feat: desktop-screenshot skill to stop agents uploading relay media to PRs (#862) feat(desktop): star channels (Slack-style favorites) (#860) fix(desktop): handle symlinked persona pack directories (#859) feat: channel muting for desktop and mobile (#838) feat(acp): default SPROUT_ACP_MEMORY to on (#854) fix(desktop): eliminate image-hover layout jump in messages (#813) chore(release): release version 0.3.10 (#849) fix(desktop): harden relay mesh connect p-tag (#834) fix(desktop): scroll activity panel to bottom on open (#848) Polish desktop profile menu interactions (#836) fix(desktop): outline thread hover targets (#845) fix(desktop): keep message actions hover-only (#844) fix(desktop): let inbox composer fill available width (#841) ...
* origin/main: (39 commits) docs: add VISION_MESH.md — the compute-commons vision (#867) fix(desktop): simplify profile popover header (#853) fix(desktop): remove thread comment hover outline (#861) feat(desktop): always show channel section search/add buttons (#856) chore(release): release version 0.3.11 (#865) fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843) feat(mobile): star channels (Slack-style favorites) (#863) feat: desktop-screenshot skill to stop agents uploading relay media to PRs (#862) feat(desktop): star channels (Slack-style favorites) (#860) fix(desktop): handle symlinked persona pack directories (#859) feat: channel muting for desktop and mobile (#838) feat(acp): default SPROUT_ACP_MEMORY to on (#854) fix(desktop): eliminate image-hover layout jump in messages (#813) chore(release): release version 0.3.10 (#849) fix(desktop): harden relay mesh connect p-tag (#834) fix(desktop): scroll activity panel to bottom on open (#848) Polish desktop profile menu interactions (#836) fix(desktop): outline thread hover targets (#845) fix(desktop): keep message actions hover-only (#844) fix(desktop): let inbox composer fill available width (#841) ... Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Summary
<DialogPrimitive.Trigger asChild>cloned onto a wrapper<div>around the<img>, itself wrapped in anImageContextMenudiv. The nested wrappers + Radix attribute cloning caused a layout reflow in the surrounding inline flow on:hoverstate changes.ImageBlockcomponent that renders a bare<img>and drives the lightbox via controlledopenstate instead ofTrigger asChild. Right-click-to-download and click-to-zoom still work.ImageBlockexplaining why the bare<img>matters, so this isn't accidentally reintroduced.Diagnostic journey
The cause wasn't obvious from reading the code — the wrapper had no
hover:border/hover:ringstyles, and the message action bar (the other hover-reactive element) is absolutely positioned. We narrowed it down by:transition-opacity hover:opacity-90on the image wrapper → bug persisted.<img>→ bug fixed on hover-enter, partial fix on hover-leave; then fully fixed once tested in isolation.Step 3 isolated the cause to the wrapper stack (specifically
Trigger asChild+ nested divs around the img). The fix keeps the<img>bare and moves the dialog/menu wiring into React state.Test plan
just cilocally.Made with Cursor