feat(desktop): show full session title in a tooltip when it truncates

Hovering a sidebar session row (one-line or card) now shows the complete
title in a styled tooltip — but only when the title actually overflows
its label, so fully visible titles never grow a redundant tip.

New OverflowTip primitive in ui/tooltip.tsx: a controlled Tip that
measures scrollWidth vs clientWidth on pointerenter and arms a 600ms
deliberate-hover delay only when the content is truncated. Community
request via @fhreire on X.
This commit is contained in:
Teknium 2026-08-13 01:50:17 -07:00
parent d254ad616f
commit ae56c97c60
3 changed files with 164 additions and 16 deletions

View File

@ -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(
<SidebarSessionRow

View File

@ -9,7 +9,7 @@ import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { openSession } from '@/app/open-session'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { OverflowTip, Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@ -438,13 +438,15 @@ function SidebarSessionRowImpl({
<>
{leadNode}
{handoffBadge}
<SidebarRowLabel
className="hover-marquee flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
<OverflowTip label={title}>
<SidebarRowLabel
className="hover-marquee flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
</OverflowTip>
</>
)
}
@ -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. */}
<div className="-mt-[0.2em] flex min-w-0 flex-col gap-[0.3rem]">
<SidebarRowLabel
className="hover-marquee text-[0.8125rem] leading-none font-medium text-(--ui-text-primary) group-data-[working=true]:text-foreground"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
<OverflowTip label={title}>
<SidebarRowLabel
className="hover-marquee text-[0.8125rem] leading-none font-medium text-(--ui-text-primary) group-data-[working=true]:text-foreground"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
</OverflowTip>
{session.preview && rowMeta.includes('preview') ? (
<span className="min-w-0 truncate text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{session.preview}

View File

@ -170,6 +170,78 @@ function Tip({ label, children, delayDuration = TIP_DELAY_MS, ...props }: TipPro
return provided ? tip : <TooltipProvider delayDuration={delayDuration}>{tip}</TooltipProvider>
}
/** 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<number | undefined>(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.
<Tooltip onOpenChange={next => !next && close()} open={open}>
<TooltipTrigger
asChild
// Clicking the row means the user is acting on it, not reading the tip.
onPointerDown={close}
onPointerEnter={event => {
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}
</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>
)
return provided ? tip : <TooltipProvider delayDuration={delayDuration}>{tip}</TooltipProvider>
}
/** 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,