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:
parent
d254ad616f
commit
ae56c97c60
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue