Skip to content

fix(desktop): eliminate image-hover layout jump in messages#813

Merged
wesbillman merged 1 commit into
mainfrom
jump-image
Jun 4, 2026
Merged

fix(desktop): eliminate image-hover layout jump in messages#813
wesbillman merged 1 commit into
mainfrom
jump-image

Conversation

@thomaspblock

Copy link
Copy Markdown
Collaborator

Summary

  • Hovering an inline image in a message caused the body text to shift down ~1-2px relative to the username header after a short pause, leaving a visibly larger gap.
  • Root cause: the inline image was wrapped in a <DialogPrimitive.Trigger asChild> cloned onto a wrapper <div> around the <img>, itself wrapped in an ImageContextMenu div. The nested wrappers + Radix attribute cloning caused a layout reflow in the surrounding inline flow on :hover state changes.
  • Replaced the wrappers with a new ImageBlock component that renders a bare <img> and drives the lightbox via controlled open state instead of Trigger asChild. Right-click-to-download and click-to-zoom still work.
  • Added a header comment to ImageBlock explaining 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:ring styles, and the message action bar (the other hover-reactive element) is absolutely positioned. We narrowed it down by:

  1. Removing transition-opacity hover:opacity-90 on the image wrapper → bug persisted.
  2. Forcing the action bar always-visible (no fade-in transition) → bug persisted.
  3. Stripping the entire wrapper stack (Dialog, ContextMenu, inner div) and rendering a bare <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

  • Hover an inline image in a channel message → no vertical shift between the username header and body text.
  • Click an inline image → full-size lightbox opens. Esc closes; click outside the image closes.
  • Right-click an inline image → "Download image" context menu appears at the cursor; click downloads the image.
  • Right-click an image while the lightbox is open → same menu appears.
  • Hover other parts of the message (avatar, username, body text) → no regression, action bar fades in as before.
  • Run just ci locally.

Made with Cursor

@thomaspblock thomaspblock requested a review from a team as a code owner June 2, 2026 14:31

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 ImageBlock accept a layout variant that drops mt-1 max-w-sm in the grid context.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my bots, btw ^

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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>
@wesbillman wesbillman merged commit 759e5cd into main Jun 4, 2026
15 checks passed
@wesbillman wesbillman deleted the jump-image branch June 4, 2026 16:55
michaelneale added a commit that referenced this pull request Jun 5, 2026
* 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
tlongwell-block pushed a commit that referenced this pull request Jun 5, 2026
* 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)
  ...
tlongwell-block pushed a commit that referenced this pull request Jun 5, 2026
* 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>
tellaho pushed a commit that referenced this pull request Jun 8, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants