diff --git a/packages/ui/README.md b/packages/ui/README.md
index dddedb1d7b..7e1b8c741c 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -140,6 +140,7 @@ flowchart LR
Transition --> Adapter[useTreeAdapter.ts]
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
Adapter --> Sticky[StickyScroll.tsx]
+ Rows --> Hover[TreeHover.tsx]
```
The model, policy, and transitions stay pure. The adapter owns React and DOM
@@ -154,6 +155,15 @@ while the tree has focus. The package default uses inset Modern UI rows;
`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
styling.
+Labels hover with the node's text value, so truncated rows stay readable.
+Set `tooltip` for richer content or `null` to opt out. One bubble serves the
+whole tree, as in the native list: an invisible anchor moves to whatever the
+pointer reaches, taking its x from the cursor and its y from the target's box,
+the way a native hover placed at the mouse does. Each new target waits out the
+show delay, except within a row's action bar, where crossing between buttons is
+instant, the exception native grants a dense cluster of targets. Ctrl+K Ctrl+I opens the focused
+row's hover with no delay at all, and moving the focus closes it.
+
## Overlays
`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives,
@@ -171,7 +181,18 @@ tooltips.
`TooltipProvider` ancestor. Mount one provider per app so that a pointer
moving between nearby triggers skips the show delay, like native hovers.
The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
-and tooltips stop growing at half the window height.
+and tooltips stop growing at half the window height. Components that own
+their hovers fall back to a private provider when the app has none, so
+`Tree` rows and `IconButton` work unwrapped. A private provider keeps its own
+skip-delay, though, so an app with several of them makes every hover wait out
+the full delay; mount one provider and they share it. `IconButton` hints with
+its label like a native action bar item; pass `tooltip` to say something else,
+or `null` for a button that stays quiet.
+
+`HoverDelegateScope` hands every `Tooltip` inside it to one shared bubble
+instead of a bubble each, the way a VS Code list serves its rows and their
+action bars from a single hover widget. `Tree` uses it, which is also what
+lets one place decide when a hover is instant rather than delayed.
Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2bc6616926..673456aac4 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -28,6 +28,7 @@
"dependencies": {
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dropdown-menu": "^2.1.24",
+ "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tooltip": "^1.2.16",
"@vscode/codicons": "catalog:"
},
diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx
index e5d308661a..27e2f877ad 100644
--- a/packages/ui/src/components/IconButton/IconButton.tsx
+++ b/packages/ui/src/components/IconButton/IconButton.tsx
@@ -1,9 +1,10 @@
-import { type ComponentProps } from "react";
+import { type ComponentProps, type ReactNode } from "react";
import { cx } from "#cx";
import "../control.css";
import { Icon } from "../Icon/Icon";
+import { Tooltip, TooltipScope } from "../Tooltip/Tooltip";
import "./IconButton.css";
@@ -15,18 +16,20 @@ export interface IconButtonProps extends Omit<
> {
icon: CodiconName;
label: string;
+ /** Hover content; defaults to the label, `null` opts out. */
+ tooltip?: ReactNode;
}
-/* No default title: native toolbar buttons hint with the styled hover
- widget, not the browser box. Wrap in Tooltip for that. */
+/* Hints through the hover widget, never the browser title box. */
export function IconButton({
icon,
label,
+ tooltip = label,
className,
type = "button",
...props
}: IconButtonProps): React.JSX.Element {
- return (
+ const button = (
);
+ if (!tooltip) return button;
+ return (
+
+ {button}
+
+ );
}
diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx
index 68a285c53a..99b593bdc8 100644
--- a/packages/ui/src/components/Tooltip/Tooltip.tsx
+++ b/packages/ui/src/components/Tooltip/Tooltip.tsx
@@ -1,4 +1,13 @@
+import { Slot } from "@radix-ui/react-slot";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import {
+ createContext,
+ use,
+ type ComponentProps,
+ type ComponentPropsWithRef,
+ type PointerEvent,
+ type ReactNode,
+} from "react";
import { cx } from "#cx";
@@ -6,21 +15,75 @@ import "../overlay.css";
import "./Tooltip.css";
-import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react";
-
export type TooltipProviderProps = ComponentProps<
typeof TooltipPrimitive.Provider
>;
+/** VS Code's `workbench.hover.delay`. */
+const DEFAULT_DELAY_MS = 500;
+
/**
* App-level tooltip context; `Tooltip` throws without one. Sharing a single
* provider lets a pointer moving between nearby triggers skip the show delay,
- * like native hovers. The default delay is VS Code's `workbench.hover.delay`.
+ * like native hovers.
*/
-export function TooltipProvider(
- props: TooltipProviderProps,
-): React.JSX.Element {
- return ;
+export function TooltipProvider({
+ delayDuration = DEFAULT_DELAY_MS,
+ ...props
+}: TooltipProviderProps): React.JSX.Element {
+ return (
+
+
+
+ );
+}
+
+const TooltipContext = createContext(null);
+
+/** Owns tooltips without forcing a provider on consumers; defers to any app-level one. */
+export function TooltipScope({ children }: { children: ReactNode }): ReactNode {
+ return use(TooltipContext) === null ? (
+ {children}
+ ) : (
+ children
+ );
+}
+
+/** The surrounding provider's show delay, for surfaces that time their own. */
+export function useTooltipDelay(): number {
+ return use(TooltipContext) ?? DEFAULT_DELAY_MS;
+}
+
+export interface HoverTarget {
+ readonly content: ReactNode;
+ readonly element: HTMLElement;
+}
+
+/** `immediate` skips the show delay. */
+export type HoverDelegate = (
+ target: HoverTarget | undefined,
+ immediate?: boolean,
+) => void;
+
+const HoverDelegateContext = createContext(
+ undefined,
+);
+
+/**
+ * Hands every `Tooltip` inside to one shared bubble, the way a VS Code list
+ * serves its rows and their action bars from a single hover widget. Pass
+ * `undefined` to hand them back.
+ */
+export function HoverDelegateScope({
+ delegate,
+ children,
+}: {
+ delegate: HoverDelegate | undefined;
+ children: ReactNode;
+}): React.JSX.Element {
+ return (
+ {children}
+ );
}
export interface TooltipProps extends Omit<
@@ -30,6 +93,8 @@ export interface TooltipProps extends Omit<
content: ReactNode;
/** The trigger element; must accept a forwarded ref (asChild). */
children: ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
}
/** Hover bubble matching the native hover widget; requires a `TooltipProvider` ancestor. */
@@ -37,15 +102,32 @@ export function Tooltip({
content,
children,
className,
+ open,
+ onOpenChange,
...props
}: TooltipProps): React.JSX.Element {
+ const delegate = use(HoverDelegateContext);
+ if (delegate) {
+ return (
+ ) =>
+ delegate({ content, element: event.currentTarget })
+ }
+ onPointerLeave={() => delegate(undefined)}
+ >
+ {children}
+
+ );
+ }
return (
-
+ {children}
+
+
+ >
+ ),
+ }),
+ ],
+ { label: "src" },
+ ),
+];
+
+/** Leaves room above the rows, where a hover sits. */
+const hoverTree = (ariaLabel: string, selectedItemId?: string) => (
+