From f2aeea95e0920e6ea6873caafd0153ddec486fc9 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 28 Jul 2026 16:53:12 +0000 Subject: [PATCH] fix(desktop): allow multiline tooltips --- .../app/shell/hooks/use-statusbar-items.tsx | 48 ++++++++++++++----- .../src/app/shell/statusbar-controls.tsx | 8 +++- .../app/shell/statusbar-visibility.test.tsx | 27 +++++++++++ apps/desktop/src/components/ui/tooltip.tsx | 27 +++++------ 4 files changed, 82 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 15c333a6b82..0c46c343a9b 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -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 ? ( + <> + {tooltipPrimary} + {tooltipDetails.length > 0 && ( + + {tooltipDetails.map(detail => ( + {detail} + ))} + + )} + + ) : 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 ? ( + <> + {copy.backendVersion(backendVersion)} + {tooltipDetails.length > 0 && ( + + {tooltipDetails.map(detail => ( + {detail} + ))} + + )} + + ) : 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' } diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 7dbc9335f71..8a6449b8fbc 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -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 ? : item.title + const tooltipLabel = item.actionId ? ( + + ) : ( + item.title + ) const content = ( <> diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx index 9d334a0330d..f48a0ff0a11 100644 --- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx +++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx @@ -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: ( + <> + Hermes Desktop v1.2.3 + + Commit abc1234 + Branch ethie/hermes-version + + + ) + }) + ]) + + 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'), diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index 9ac1e9d1f57..f9d43a2ce35 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -96,11 +96,9 @@ function TooltipContent({ return ( - {/* 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. */} - + {/* bg-foreground/text-background auto-inverts per theme. A column keeps + primary text and optional detail rows aligned inside one panel. */} + {children} @@ -198,11 +191,16 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) { return ( {text} - {hint} + {hint} ) } +/** Muted secondary content inside a tooltip. The caller owns its layout. */ +function TooltipDetails({ className, ...props }: React.ComponentProps<'span'>) { + return +} + 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 }