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
}