diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx index 90b5211852624..e49dd7db8cd5a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { atom } from 'nanostores' import type * as React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -226,6 +226,77 @@ describe('SidebarSessionRow', () => { expect(tipTrigger(kebab)).toBeNull() }) + // Full-title tooltip on hover (#83000-class ask): the label is a tooltip + // trigger, but the tip only opens when the title is actually truncated. + describe('full-title overflow tooltip', () => { + afterEach(() => { + vi.useRealTimers() + }) + + const title = 'A very long session title that the sidebar cannot possibly fit' + + /** The rendered title label (tooltip trigger is the label itself). */ + const label = () => screen.getByText(title).closest('[data-slot="tooltip-trigger"]') as HTMLElement + + const setWidths = (el: HTMLElement, scrollWidth: number, clientWidth: number) => { + Object.defineProperty(el, 'scrollWidth', { configurable: true, value: scrollWidth }) + Object.defineProperty(el, 'clientWidth', { configurable: true, value: clientWidth }) + } + + it('wraps the title in a tooltip trigger', () => { + renderRow(makeSession({ title })) + + expect(label()).toBeTruthy() + }) + + it('opens with the full title after a settled hover when the title overflows', () => { + vi.useFakeTimers() + renderRow(makeSession({ title })) + + const el = label() + setWidths(el, 300, 100) + + act(() => { + fireEvent.pointerEnter(el) + vi.advanceTimersByTime(700) + }) + + expect(screen.getByRole('tooltip').textContent).toContain(title) + }) + + it('stays closed when the title fits', () => { + vi.useFakeTimers() + renderRow(makeSession({ title })) + + const el = label() + setWidths(el, 100, 100) + + act(() => { + fireEvent.pointerEnter(el) + vi.advanceTimersByTime(700) + }) + + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('cancels a pending open when the pointer leaves before the delay', () => { + vi.useFakeTimers() + renderRow(makeSession({ title })) + + const el = label() + setWidths(el, 300, 100) + + act(() => { + fireEvent.pointerEnter(el) + vi.advanceTimersByTime(200) + fireEvent.pointerLeave(el) + vi.advanceTimersByTime(700) + }) + + expect(screen.queryByRole('tooltip')).toBeNull() + }) + }) + it('does not render a handoff avatar for a locally-started session', () => { const { container } = render( {leadNode} {handoffBadge} - - {title} - + + + {title} + + ) } @@ -467,13 +469,15 @@ function SidebarSessionRowImpl({ {/* Title + preview: ONE grouped cell with its own tight internal gap — it does not inherit the card's rhythm. */}
- - {title} - + + + {title} + + {session.preview && rowMeta.includes('preview') ? ( {session.preview} diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index 9ac1e9d1f57a9..587f3fc7cddb2 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -170,6 +170,78 @@ function Tip({ label, children, delayDuration = TIP_DELAY_MS, ...props }: TipPro return provided ? tip : {tip} } +/** Hover-open delay for `OverflowTip`. Longer than `TIP_DELAY_MS`: the trigger + * is a row's own content (not a control), so the tip should only appear on a + * deliberate, lingering hover — a cursor travelling the list must not pop a + * trail of titles. */ +const OVERFLOW_TIP_DELAY_MS = 600 + +/** + * A `Tip` that only opens when the trigger's content is actually truncated + * (its `scrollWidth` exceeds its `clientWidth` at pointerenter). A tooltip that + * repeats a fully visible label is noise, and Radix's uncontrolled hover-open + * can't see overflow — so this owns `open` and arms its own timer after + * measuring. Pointer-only by design: keyboard focus keeps the child's existing + * a11y affordances (the full text is already in the accessible name). + * + * Measurement happens on the CHILD element (`asChild` puts the trigger props on + * it), so wrap the element that carries the truncation/overflow styling. + */ +function OverflowTip({ label, children, delayDuration = OVERFLOW_TIP_DELAY_MS, ...props }: TipProps) { + const provided = React.useContext(HasTooltipProvider) + const [open, setOpen] = React.useState(false) + const timer = React.useRef(undefined) + + const cancel = React.useCallback(() => { + if (timer.current !== undefined) { + window.clearTimeout(timer.current) + timer.current = undefined + } + }, []) + + // A row unmounting mid-hover (list refresh, filter) must not fire a stale + // timer into a torn-down tooltip. + React.useEffect(() => cancel, [cancel]) + + if (!label) { + return <>{children} + } + + const close = () => { + cancel() + setOpen(false) + } + + const tip = ( + // Controlled: only closes are honored from Radix (Escape, pointer-down + // grace); opens are ours, gated on the measured overflow below. + !next && close()} open={open}> + { + const el = event.currentTarget + + cancel() + + // Same 2px slack the sidebar marquee uses: sub-pixel rounding can + // report a 1px "overflow" on a title that fully fits. + if (el.scrollWidth - el.clientWidth > 2) { + timer.current = window.setTimeout(() => setOpen(true), delayDuration) + } + }} + onPointerLeave={close} + > + {children} + + {label} + + ) + + return provided ? tip : {tip} +} + /** The app's single tooltip provider. Mounted once at the root so no `Tip` * needs its own. Defaults match what `Tip` used to pass per instance. */ function RootTooltipProvider({ children }: { children: React.ReactNode }) { @@ -223,6 +295,7 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) { } export { + OverflowTip, RootTooltipProvider, Tip, TipHintLabel,