fix(desktop): allow multiline tooltips

This commit is contained in:
ethernet 2026-07-28 16:53:12 +00:00
parent 6cbd3a6ac3
commit f2aeea95e0
4 changed files with 82 additions and 28 deletions

View file

@ -8,6 +8,7 @@ import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { TooltipDetails } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
@ -233,15 +234,27 @@ export function useStatusbarItems({
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
: `${base}${behindHint}`
const tooltip = [
const tooltipPrimary = appVersion ? copy.desktopVersion(appVersion) : (sha ? copy.commit(sha) : null)
const tooltipDetails = [
applying ? updateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, branch ?? '...'),
appVersion && copy.desktopVersion(appVersion),
sha && copy.commit(sha),
appVersion && sha && copy.commit(sha),
branch && copy.branch(branch)
]
.filter(Boolean)
.join('\n')
.filter((detail): detail is string => Boolean(detail))
const tooltip = tooltipPrimary ? (
<>
<span>{tooltipPrimary}</span>
{tooltipDetails.length > 0 && (
<TooltipDetails className="flex flex-col gap-0.5">
{tooltipDetails.map(detail => (
<span key={detail}>{detail}</span>
))}
</TooltipDetails>
)}
</>
) : undefined
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
@ -253,7 +266,7 @@ export function useStatusbarItems({
// their client is behind. Listed in the menu, but locked on.
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('client'),
title: tooltip || undefined,
title: tooltip,
toggleLabel: copy.toggleVersion,
variant: 'action'
}
@ -290,14 +303,25 @@ export function useStatusbarItems({
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
: `${base}${behindHint}`
const tooltip = [
const tooltipDetails = [
applying ? backendUpdateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
!applying && behind <= 0 && updateAvailable && copy.update,
backendVersion && copy.backendVersion(backendVersion)
!applying && behind <= 0 && updateAvailable && copy.update
]
.filter(Boolean)
.join('\n')
.filter((detail): detail is string => Boolean(detail))
const tooltip = backendVersion ? (
<>
<span>{copy.backendVersion(backendVersion)}</span>
{tooltipDetails.length > 0 && (
<TooltipDetails className="flex flex-col gap-0.5">
{tooltipDetails.map(detail => (
<span key={detail}>{detail}</span>
))}
</TooltipDetails>
)}
</>
) : undefined
return {
className: !applying && updateAvailable ? 'text-primary hover:text-primary' : undefined,
@ -307,7 +331,7 @@ export function useStatusbarItems({
label,
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
title: tooltip,
toggleLabel: copy.toggleBackendVersion,
variant: 'action'
}

View file

@ -57,7 +57,7 @@ export interface StatusbarItem {
onSelect?: (modifiers: StatusbarSelectModifiers) => void
/** Keybind action id — when set, the tooltip shows the label + keybind hint. */
actionId?: string
title?: string
title?: ReactNode
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
/** Plain-text name for the bar's right-click show/hide menu. An item without
@ -212,7 +212,11 @@ const StatusbarItemView = memo(function StatusbarItemView({
return <>{item.render()}</>
}
const tooltipLabel = item.actionId ? <TipKeybindLabel actionId={item.actionId} text={item.title} /> : item.title
const tooltipLabel = item.actionId ? (
<TipKeybindLabel actionId={item.actionId} text={typeof item.title === 'string' ? item.title : undefined} />
) : (
item.title
)
const content = (
<>

View file

@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
import { TooltipDetails } from '@/components/ui/tooltip'
import {
$statusbarHiddenIds,
$statusbarVisible,
@ -55,6 +56,32 @@ function openContextMenu(target: HTMLElement) {
}
describe('statusbar item visibility', () => {
it('groups version-tooltip details below its primary label in one panel', async () => {
bar([
item('version-client', 'v1.2.3', {
title: (
<>
<span>Hermes Desktop v1.2.3</span>
<TooltipDetails className="flex flex-col gap-0.5">
<span>Commit abc1234</span>
<span>Branch ethie/hermes-version</span>
</TooltipDetails>
</>
)
})
])
fireEvent.pointerMove(screen.getByRole('button', { name: 'v1.2.3' }), { pointerType: 'mouse' })
const tooltip = await screen.findByRole('tooltip')
const label = tooltip.firstElementChild
expect(tooltip.textContent).toBe('Hermes Desktop v1.2.3Commit abc1234Branch ethie/hermes-version')
expect(label?.classList.contains('bg-foreground')).toBe(true)
expect(label?.classList.contains('flex-col')).toBe(true)
expect(screen.getByText('Commit abc1234').parentElement?.classList.contains('text-background/65')).toBe(true)
})
it('hides the route/toggle items out of the box and keeps status items', () => {
bar([
item('cron', 'Cron'),

View file

@ -96,11 +96,9 @@ function TooltipContent({
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
// Transparent, width-capped wrapper. The visible chip is the inner inline
// span so `box-decoration-break: clone` gives a marker-style background
// that hugs EACH wrapped line (bg only on the text, ragged right — no
// rectangular dead space). No fade transition — once the hover delay
// elapses the chip appears at once.
// Transparent, width-capped wrapper. The visible panel is the inner span
// so all lines share one stable background. No fade transition — once
// the hover delay elapses the panel appears at once.
// pointer-events-none: the tip must never steal hover/clicks from the
// chrome underneath (titlebar tools, adjacent tabs, etc.).
className={cn('pointer-events-none z-(--z-over-modal) w-fit max-w-64 select-none', className)}
@ -108,14 +106,9 @@ function TooltipContent({
sideOffset={sideOffset}
{...props}
>
{/* bg-foreground/text-background auto-inverts per theme. leading-normal
keeps lines readable; py-1 makes the cloned line-boxes overlap just
enough to read as one continuous fill (no gaps between lines). */}
{/* [&>*]:!inline-flex: a block-level label child (e.g. `flex`) collapses
this inline decoration's geometry, so Radix measures a zero-size chip
and parks an empty rectangle in the corner (#62022). Force any direct
child inline-flex so every call site stays safe. */}
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif] [&>*]:!inline-flex">
{/* bg-foreground/text-background auto-inverts per theme. A column keeps
primary text and optional detail rows aligned inside one panel. */}
<span className="inline-flex max-w-full flex-col items-start gap-1 bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif]">
{children}
</span>
</TooltipPrimitive.Content>
@ -198,11 +191,16 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) {
return (
<span className="inline-flex items-center gap-2">
<span>{text}</span>
<span className="opacity-55">{hint}</span>
<TooltipDetails>{hint}</TooltipDetails>
</span>
)
}
/** Muted secondary content inside a tooltip. The caller owns its layout. */
function TooltipDetails({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn('text-background/65 font-normal', className)} {...props} />
}
interface TipKeybindLabelProps {
/** Keybind action id — pulls the label from i18n AND the combo from the store. */
actionId: string
@ -229,6 +227,7 @@ export {
TipKeybindLabel,
Tooltip,
TooltipContent,
TooltipDetails,
TooltipProvider,
TooltipTrigger
}