Skip to content

feat(ui): serve tree and action-bar hovers from one native-style widget - #1078

Merged
EhabY merged 2 commits into
mainfrom
feat/ui-tree-hovers
Sep 8, 2026
Merged

feat(ui): serve tree and action-bar hovers from one native-style widget#1078
EhabY merged 2 commits into
mainfrom
feat/ui-tree-hovers

Conversation

@EhabY

@EhabY EhabY commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Third of three stacked PRs: #1065#1079this one. Gives tree rows and icon buttons the hover VS Code shows, served the way the workbench serves it: one widget per list rather than one per target.

  • A row hovers with its text value; tooltip overrides it and null opts out
  • IconButton hints with its label, so a row's action bar needs no wiring
  • Ctrl+K Ctrl+I opens the focused row's hover, the chord bound to list.showHover
  • One bubble for the whole tree, placed and delayed the way native places and delays it

Why one widget

Tooltip mounts a Radix root per trigger, which is right for a button and wrong for a list. Two reasons.

Cost. Mounting 2,000 rows took 515ms with a root each, against 134ms with no hovers at all. A shared widget brings it back to 189ms, within noise of a tree that has none.

Correctness. Native computes delay = isInstantlyHovering() ? 0 : workbench.hover.delay, where "instantly" means a hover was hidden within the last 200ms. That rule needs one place to keep the timestamp, and a root per trigger has none: trigger B cannot know that A was showing a moment ago. Radix answers this with skipDelayDuration on its provider, but our bubble is a controlled root and never reports its opens there, so the two would keep separate ideas of what is on screen.

So HoverDelegateScope hands every Tooltip inside it to one bubble. Tooltip reports its trigger and content to that delegate instead of mounting a root, TreeHover owns the widget, and an invisible anchor moves to whatever the pointer reached.

Placement, from the managed-hover path

A managed hover always passes position: { hoverPosition: 2 }, whatever the widget's own default of 3 is, so it sits below its target, 2px into the bottom edge. The same line sets showPointer: hoverDelegate.placement === "element", and computeXCordinate centers whenever a pointer is shown. That splits into two rules, both matched here:

Target Native placement Result
Action bar button element centered under the button
Row label mouse follows the cursor, target.x = event.x + 10

Measured in Chrome: an action bubble's center lands on the button's center to the pixel, and a row bubble's left edge tracks the cursor at exactly +10 across positions. The anchor takes its y from the row rather than from whichever element reported the pointer, so a label taller than its row, which is what a status pill produces, cannot push the bubble off it.

Matching native's timing

Native gates the zero-delay path on a per-delegate instantHover flag, and exactly three call sites set it: a notebook cell toolbar and two inline widgets. Never a list. So each new target waits out the delay here too, with the same exception native carves out for a dense cluster of targets, which for a tree means one row's action bar.

With workbench.hover.delay at its 500ms default:

Move Time
First hover 518ms
Row to another row 511ms
Label to an action button 513ms
Action button to action button 19ms

Notes for review

  • @radix-ui/react-slot becomes a direct dependency of packages/ui. It was already there transitively, and it merges the delegate's pointer handlers onto a trigger while composing any the consumer passed.
  • TooltipScope lets a component own hovers without forcing a provider on its consumers, so Tree and IconButton work unwrapped. A private provider keeps its own skip-delay, so an app with several of them makes every hover wait the full delay; the README says to mount one provider per app.
  • TreeHover uses plain functions rather than useCallback. The React Compiler covers the memoization, and the file compiles with no bailout, so the delegate a row reads stays stable without it.
  • Native pins the sticky container at z-index: 13, below the hover widget's 40. Ours used 100, so a bubble over a row near the top of the tree was painted over by the pinned rows.
  • Drive-by: the menu parity story loses its trigger button so both menus start at the same height, which the pixel diff was reading as drift.
  • A tree hover is not linked to its row with aria-describedby. The bubble hangs off a shared anchor, and rows are reached through aria-activedescendant rather than focus, so the association would not be announced. The default content is the row's own accessible name.

Diff composition

Area Added Deleted
Production UI source 418 82
Automated tests and shared helpers 123 4
Stories and visual fixtures 118 7
Documentation 22 1
Build and workspace configuration 4 0
Total 685 94

Validation

  • pnpm test:webview --reporter=dot: 45 files, 418 tests passed, up from 414 on feat(ui): add tree multi-select, type navigation, paging, and sticky scroll #1079
  • Focused Tree suite: 10 files, 99 tests passed
  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm storybook:ci
  • Three new stories, Hover, HoverOnAction, and HoverByKeyboard, drive the bubble through a play function and assert its content. Note that a synthetic pointer never triggers CSS :hover, so the action-bar story selects its row to reveal the buttons and the helper waits for a laid-out box before hovering.
  • Verified in Chrome against a running Storybook: cursor tracking, centering on an action button, the timings above, stacking over pinned rows, and hovers on pinned sticky rows, whose coordinates come from a different container
  • Every component in packages/ui still compiles under the React Compiler with no bailout

This pull request description was updated by Coder Agents on behalf of @EhabY.

@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 0009336 to 62c66f0 Compare August 14, 2026 16:00
@EhabY
EhabY changed the base branch from feat/ui-tree-suite to feat/ui-tree-interaction August 14, 2026 16:01
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch 2 times, most recently from 9a72bed to 46e0d09 Compare August 14, 2026 16:55
@EhabY EhabY changed the title feat(ui): serve tree hovers from one native-style widget feat(ui): serve tree and action-bar hovers from one native-style widget Aug 14, 2026
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 46e0d09 to 372cd6f Compare August 17, 2026 12:07
@EhabY EhabY self-assigned this Aug 18, 2026
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 372cd6f to d6e544b Compare August 18, 2026 12:55
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from d6e544b to 5c3c9cc Compare August 24, 2026 09:04
@EhabY
EhabY marked this pull request as ready for review August 24, 2026 09:32
@EhabY
EhabY requested a review from jakehwll August 24, 2026 10:18
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 5c3c9cc to 2e362fb Compare August 25, 2026 11:31
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 2e362fb to 79e4dd5 Compare August 25, 2026 23:14

@jakehwll jakehwll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm. Weird that Radix is causing an issue here... code-wise and test wise this works however 😅

@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 79e4dd5 to 0e67c16 Compare September 8, 2026 11:20
Base automatically changed from feat/ui-tree-interaction to main September 8, 2026 11:26
Rows hover with their text value, as VS Code's tree falls back to the
label when an item has no tooltip; `tooltip` overrides it and null opts
out. Ctrl+K Ctrl+I opens the focused row's hover, the chord bound to
list.showHover, and IconButton hints with its label like an action bar
item.

One bubble serves a whole tree rather than one per trigger, the way a
list hands a single hover delegate to its rows and their action bars.
Rows report the element under the pointer through `HoverDelegateScope`
and an invisible anchor moves to it. That single widget is also the only
place a delay rule can live, and native's rule needs one: each new target
waits out `workbench.hover.delay`, except inside a row's action bar, the
dense cluster native grants an instant handoff.

A managed hover always uses hoverPosition 2, whatever the widget's own
default, so the bubble sits 2px into the bottom edge of its target rather
than above it. Placement then splits on the delegate: an element-placed
hover, which is what an action bar button gets, centers under it, while a
mouse-placed one, which is what a row gets, follows the cursor.

Measured in Chrome: label to a button 513ms, button to button 19ms, and
mounting 2,000 rows 189ms against 515ms for a Radix root per row. Also
pins the sticky container at native's z-index 13, below the hover's 40,
so pinned rows no longer paint over a bubble, and drops the menu parity
story's trigger button so both menus line up.
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 0e67c16 to d25949d Compare September 8, 2026 11:26
Cancel pending and visible tree hovers on pointer presses, reject hidden or detached targets, and dismiss immediately on keyboard or blur. Restore the parity menu anchor and keep selection stories hoverless. Cover the hover lifecycle with concise, parameterized unit tests.

Generated by Coder Agents on behalf of @EhabY.
@EhabY
EhabY force-pushed the feat/ui-tree-hovers branch from 6532c01 to 7c8a22b Compare September 8, 2026 12:02
@EhabY
EhabY merged commit d9b0131 into main Sep 8, 2026
11 of 12 checks passed
@EhabY
EhabY deleted the feat/ui-tree-hovers branch September 8, 2026 12:07
pull Bot pushed a commit to Hawthorne001/vscode-coder that referenced this pull request Sep 8, 2026
…scroll (coder#1079)

Second of three stacked PRs (coder#1065 -> this one -> coder#1078), adding the interaction VS Code trees support beyond moving and selecting one row at a time. Multi-selection puts a range anchor in the interaction state alongside focus and selection, and sticky scroll needs the pointer policy to know which surface a click originated from, which is why these land together rather than as separate PRs. Multi-select, Ctrl/Cmd+A scoping, type navigation, and paging build on the pure model, policy, and transition layers from coder#1065, while sticky scroll adds its own pinned region with independent tab-stop behavior.

- Multi-selection with selectedItemIds, anchored ranges, and modifier toggling (multiSelectModifier: "ctrlCmd" or "alt")
- Ctrl/Cmd+A scoped the way list.selectAll scopes it for a tree: visible descendants of the focused row's parent, widening once fully selected
- Buffered prefix and fuzzy type navigation, with an 800ms buffer expiry and repeated-key cycling
- PageUp and PageDown measured against the scroller's viewport
- Sticky scroll with stickyScrollMaxItemCount and 40% viewport caps, per-level indentation, and its own keyboard tab stop
- 1,916 lines added / 149 deleted, generalizing single-selection shapes into multi-select-capable discriminated unions
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