fix(web): keep machine names visible and health tooltips touchable (#1049)

* fix(web): improve machine health sidebar UX

Keep machine names visible, align health metrics, and make nested health tooltips usable with touch and keyboard input.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): preserve tooltip focus reveal groups

Keep the unnamed group used by existing focus reveal classes while retaining named hover groups for nested machine health tooltips.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add exec* timestamps to ToolCard test fixture

Unblocks typecheck after #1036 made execStartedAt/execCompletedAt
required on ChatToolCall; fixture was missing both fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align machine health status with meters

Right-align the capacity status with the utilization meter edge to balance the tooltip header without shortening the bars.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): restore machine health disclosure semantics

Expose the machine group's expanded state on its toggle and describe the health trigger with the tooltip body for assistive technologies.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ananovo
2026-07-19 12:24:09 +08:00
committed by GitHub
co-authored by Cursor HAPI Debian
parent f1b5ed5e5d
commit 77f94ef738
9 changed files with 186 additions and 121 deletions
+18 -3
View File
@@ -1,4 +1,4 @@
import { useId, type ReactNode } from 'react'
import { useId, type ReactNode, type Ref } from 'react'
import { cn } from '@/lib/utils'
/** Tailwind classes that reveal the bubble when a named parent row has :focus-visible. */
@@ -39,10 +39,14 @@ export function HoverTooltip(props: {
revealOnParentFocusClass?: string
/** Optional classes for the tooltip panel (e.g. wider popover). */
tooltipClassName?: string
open?: boolean
containerRef?: Ref<HTMLSpanElement>
hoverGroup?: 'default' | 'help'
}) {
const side = props.side ?? 'bottom'
const align = props.align ?? 'center'
const spansRow = align === 'row'
const isHelpGroup = props.hoverGroup === 'help'
const alignClasses = spansRow
? 'left-1 right-1 w-auto'
@@ -51,7 +55,15 @@ export function HoverTooltip(props: {
: 'left-1/2 -translate-x-1/2'
return (
<span className={cn(spansRow ? 'static' : 'relative', 'inline-flex group', props.className)}>
<span
ref={props.containerRef}
className={cn(
spansRow ? 'static' : 'relative',
'inline-flex group',
isHelpGroup ? 'group/help-tooltip' : 'group/hover-tooltip',
props.className
)}
>
<span className="inline-flex">
{props.target}
</span>
@@ -66,8 +78,11 @@ export function HoverTooltip(props: {
side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
alignClasses,
'opacity-0 invisible',
'group-hover:opacity-100 group-hover:visible',
isHelpGroup
? 'group-hover/help-tooltip:opacity-100 group-hover/help-tooltip:visible'
: 'group-hover/hover-tooltip:opacity-100 group-hover/hover-tooltip:visible',
props.revealOnParentFocusClass,
props.open && 'opacity-100 visible',
props.tooltipClassName,
'transition-opacity duration-100'
)}
+18 -8
View File
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { Machine } from '@/types/api'
import { MachineGroupHeader } from './MachineGroupHeader'
import { I18nProvider } from '@/lib/i18n-context'
@@ -23,14 +23,15 @@ const machine: Machine = {
}
describe('MachineGroupHeader', () => {
it('renders a single-row machine tile with os label and compact health', () => {
it('renders a single-row machine tile with machine name and compact health', () => {
const onToggle = vi.fn()
render(
<I18nProvider>
<MachineGroupHeader
label="Teemo"
sessionCount={4}
collapsed={false}
onToggle={() => {}}
onToggle={onToggle}
machine={machine}
healthPresentation={{
metrics: [
@@ -44,13 +45,21 @@ describe('MachineGroupHeader', () => {
</I18nProvider>
)
expect(screen.getByRole('button', { name: /Teemo/i })).toBeTruthy()
expect(screen.getByText('Windows')).toBeTruthy()
const machineButton = screen.getByRole('button', { name: /Teemo/i })
expect(machineButton.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(machineButton)
expect(onToggle).toHaveBeenCalledTimes(1)
expect(screen.queryByText('Windows')).toBeNull()
expect(screen.getByText('(4)')).toBeTruthy()
expect(screen.getByLabelText(/CPU 12 percent; RAM 88 percent/i)).toBeTruthy()
const healthButton = screen.getByRole('button', { name: /CPU 12 percent; RAM 88 percent/i })
fireEvent.click(healthButton)
expect(healthButton.getAttribute('aria-expanded')).toBe('true')
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('shows compact uptime in the machine meta row', () => {
it('keeps uptime in the health tooltip instead of replacing the machine name', () => {
render(
<I18nProvider>
<MachineGroupHeader
@@ -75,6 +84,7 @@ describe('MachineGroupHeader', () => {
</I18nProvider>
)
expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy()
expect(screen.getByTitle('proxmox')).toBeTruthy()
expect(screen.getByText('1h 54m')).toBeTruthy()
})
})
+14 -52
View File
@@ -1,16 +1,9 @@
import { useId } from 'react'
import type { Machine } from '@/types/api'
import { MACHINE_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip'
import { MachineHealthIndicator } from '@/components/MachineHealthIndicator'
import {
getMachineHost,
getMachinePlatform,
resolveMachineOsLabel,
shouldShowMachineHostSubtitle,
type MachineHealthPresentation,
} from '@/lib/machineHealth'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
function MachineIcon(props: { className?: string }) {
return (
@@ -56,16 +49,6 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
)
}
function formatOsLabel(
osLabel: ReturnType<typeof resolveMachineOsLabel>,
t: (key: string) => string
): string {
if (osLabel.kind === 'raw') {
return osLabel.value
}
return t(osLabel.key)
}
export function MachineGroupHeader(props: {
label: string
sessionCount: number
@@ -74,60 +57,39 @@ export function MachineGroupHeader(props: {
machine?: Machine
healthPresentation: MachineHealthPresentation | null
}) {
const { t } = useTranslation()
const healthTooltipId = useId()
const platform = getMachinePlatform(props.machine)
const host = getMachineHost(props.machine)
const osLabel = resolveMachineOsLabel(platform)
const osText = formatOsLabel(osLabel, t)
const showHost = shouldShowMachineHostSubtitle(props.label, host)
const uptimeText = props.healthPresentation?.uptimeDetail
const metaParts = [osText]
if (showHost && host) {
metaParts.push(host)
}
if (uptimeText) {
metaParts.push(t('machine.health.uptimeCompact', { value: uptimeText }))
}
const machineMeta = metaParts.join(' · ')
const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0
return (
<button
type="button"
onClick={props.onToggle}
aria-describedby={hasHealth ? healthTooltipId : undefined}
<div
className={cn(
'group/machine-row relative flex w-full min-w-0 items-center gap-2 px-1 py-1.5 text-left rounded-lg select-none',
'border border-[var(--app-border)] bg-[var(--app-subtle-bg)]/70',
'transition-colors hover:bg-[var(--app-subtle-bg)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]'
'transition-colors hover:bg-[var(--app-subtle-bg)]'
)}
>
<ChevronIcon className="h-4 w-4 shrink-0 text-[var(--app-hint)]" collapsed={props.collapsed} />
<MachineIcon className="h-4 w-4 shrink-0 text-[var(--app-link)]/80" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--app-fg)]">
{props.label}
</span>
<span
className="min-w-0 max-w-[8rem] shrink truncate text-[11px] text-[var(--app-hint)]"
title={machineMeta}
<button
type="button"
onClick={props.onToggle}
aria-expanded={!props.collapsed}
className="flex min-w-0 flex-1 items-center gap-2 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
{machineMeta}
</span>
<ChevronIcon className="h-4 w-4 shrink-0 text-[var(--app-hint)]" collapsed={props.collapsed} />
<MachineIcon className="h-4 w-4 shrink-0 text-[var(--app-link)]/80" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--app-fg)]" title={props.label}>
{props.label}
</span>
</button>
{hasHealth ? (
<MachineHealthIndicator
presentation={props.healthPresentation!}
layout="inline"
compact
className="shrink-0"
tooltipId={healthTooltipId}
revealOnParentFocusClass={MACHINE_ROW_TOOLTIP_FOCUS_CLASS}
/>
) : null}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-[var(--app-hint)]">
({props.sessionCount})
</span>
</button>
</div>
)
}
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { MachineHealthIndicator } from './MachineHealthIndicator'
import { I18nProvider } from '@/lib/i18n-context'
@@ -22,10 +22,30 @@ describe('MachineHealthIndicator', () => {
</I18nProvider>
)
expect(screen.getByText('CPU')).toBeTruthy()
expect(screen.getByText('RAM')).toBeTruthy()
expect(screen.getByText('CPU across all 6 cores')).toBeTruthy()
expect(screen.getByLabelText(/CPU 72/i)).toBeTruthy()
expect(screen.getAllByText('CPU')).toHaveLength(2)
expect(screen.getAllByText('RAM')).toHaveLength(2)
expect(screen.getByLabelText('Updated every ~20s from the runner on this machine')).toBeTruthy()
const healthButton = screen.getByRole('button', { name: /CPU 72/i })
fireEvent.click(healthButton)
expect(healthButton.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(healthButton)
expect(healthButton.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(healthButton)
fireEvent.pointerDown(document.body)
expect(healthButton.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(healthButton)
fireEvent.keyDown(healthButton, { key: 'Escape' })
expect(healthButton.getAttribute('aria-expanded')).toBe('false')
const helpButton = screen.getByRole('button', { name: 'Updated every ~20s from the runner on this machine' })
fireEvent.click(helpButton)
expect(helpButton.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(helpButton)
expect(helpButton.getAttribute('aria-expanded')).toBe('false')
})
it('renders inline percent labels', () => {
+106 -19
View File
@@ -1,9 +1,8 @@
import { useId } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { HoverTooltip } from '@/components/HoverTooltip'
import {
MACHINE_HEALTH_BAR_FILL_CLASS,
MACHINE_HEALTH_CHIP_CLASS,
getCpuMetricTooltipLabel,
type MachineHealthMetricPresentation,
type MachineHealthPresentation
} from '@/lib/machineHealth'
@@ -51,7 +50,7 @@ function TooltipMetricStat(props: {
label: string
}) {
return (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap">
<span className="grid grid-cols-[3.75rem_2.25rem_minmax(3.5rem,1fr)] items-center gap-x-1 whitespace-nowrap">
<span className="text-[var(--app-hint)]">{props.label}</span>
<span
className={cn(
@@ -62,7 +61,7 @@ function TooltipMetricStat(props: {
{props.metric.percent}%
</span>
<span
className="relative h-1.5 w-14 overflow-hidden rounded-full bg-[var(--app-border)]/80"
className="relative h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-border)]/80"
aria-hidden="true"
>
<span
@@ -74,6 +73,64 @@ function TooltipMetricStat(props: {
)
}
function MachineHealthHint() {
const { t } = useTranslation()
const tooltipId = useId()
const [clickOpen, setClickOpen] = useState(false)
const containerRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!clickOpen) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setClickOpen(false)
}
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [clickOpen])
const target = (
<button
type="button"
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-current text-[9px] font-semibold leading-none text-[var(--app-hint)]"
aria-label={t('machine.health.tooltip.hint')}
aria-expanded={clickOpen}
aria-controls={tooltipId}
onClick={(event) => {
event.stopPropagation()
if (clickOpen) {
event.currentTarget.blur()
}
setClickOpen((open) => !open)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setClickOpen(false)
}
}}
>
?
</button>
)
return (
<HoverTooltip
id={tooltipId}
target={target}
side="bottom"
align="start"
open={clickOpen}
containerRef={containerRef}
hoverGroup="help"
tooltipClassName="pointer-events-auto w-56"
>
{t('machine.health.tooltip.hint')}
</HoverTooltip>
)
}
function MachineHealthTooltipBody(props: {
presentation: MachineHealthPresentation
}) {
@@ -83,22 +140,23 @@ function MachineHealthTooltipBody(props: {
return (
<span className="block space-y-1.5">
<span className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
<span className="font-medium">{t('machine.health.tooltip.title')}</span>
<span className="text-[var(--app-fg)]">{t(statusKey)}</span>
<span className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="inline-flex items-center gap-1 font-medium">
{t('machine.health.tooltip.title')}
<MachineHealthHint />
</span>
<span className="text-right text-[var(--app-fg)]">{t(statusKey)}</span>
</span>
<span className="flex flex-wrap items-center gap-x-5 gap-y-1">
<span className="block space-y-1">
{presentation.metrics.map((metric) => (
<TooltipMetricStat
key={metric.id}
metric={metric}
label={metric.id === 'cpu'
? getCpuMetricTooltipLabel(presentation.cpuCount, t)
: t(`machine.health.metric.${metric.id}`, { n: metric.percent })}
label={t(`machine.health.metric.${metric.id}`, { n: metric.percent })}
/>
))}
{presentation.loadDetail ? (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap text-[var(--app-hint)]">
<span className="grid grid-cols-[3.75rem_1fr] items-center gap-x-1 whitespace-nowrap text-[var(--app-hint)]">
<span>{t('machine.health.tooltip.loadShort')}</span>
<span className="font-semibold tabular-nums text-[var(--app-fg)]">
{presentation.loadDetail}
@@ -106,7 +164,7 @@ function MachineHealthTooltipBody(props: {
</span>
) : null}
{presentation.uptimeDetail ? (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap text-[var(--app-hint)]">
<span className="grid grid-cols-[3.75rem_1fr] items-center gap-x-1 whitespace-nowrap text-[var(--app-hint)]">
<span>{t('machine.health.tooltip.uptimeShort')}</span>
<span className="font-semibold tabular-nums text-[var(--app-fg)]">
{presentation.uptimeDetail}
@@ -114,9 +172,6 @@ function MachineHealthTooltipBody(props: {
</span>
) : null}
</span>
<span className="block text-[11px] leading-snug text-[var(--app-hint)]">
{t('machine.health.tooltip.hint')}
</span>
</span>
)
}
@@ -133,6 +188,20 @@ export function MachineHealthIndicator(props: {
const generatedTooltipId = useId()
const tooltipId = props.tooltipId ?? generatedTooltipId
const { presentation, layout = 'stack', compact = false } = props
const [clickOpen, setClickOpen] = useState(false)
const containerRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!clickOpen) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setClickOpen(false)
}
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [clickOpen])
const ariaLabel = presentation.metrics.length > 0
? presentation.metrics
@@ -141,7 +210,8 @@ export function MachineHealthIndicator(props: {
: t('machine.health.aria.unknown')
const chip = (
<span
<button
type="button"
className={cn(
'inline-flex rounded-md border',
compact ? 'flex-row flex-nowrap items-center gap-x-1.5 px-1 py-0.5' : layout === 'inline'
@@ -151,6 +221,21 @@ export function MachineHealthIndicator(props: {
props.className
)}
aria-label={ariaLabel}
aria-describedby={tooltipId}
aria-expanded={clickOpen}
aria-controls={tooltipId}
onClick={(event) => {
event.stopPropagation()
if (clickOpen) {
event.currentTarget.blur()
}
setClickOpen((open) => !open)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setClickOpen(false)
}
}}
>
{presentation.metrics.map((metric) => (
<HealthMeterBar
@@ -162,7 +247,7 @@ export function MachineHealthIndicator(props: {
compact={compact}
/>
))}
</span>
</button>
)
return (
@@ -172,8 +257,10 @@ export function MachineHealthIndicator(props: {
side="bottom"
align="end"
className="shrink-0"
tooltipClassName="px-3 py-2 min-w-[16rem]"
tooltipClassName="pointer-events-auto before:absolute before:inset-x-0 before:-top-1 before:h-1 before:content-[''] px-3 py-2 min-w-[16rem]"
revealOnParentFocusClass={props.revealOnParentFocusClass}
open={clickOpen}
containerRef={containerRef}
>
<MachineHealthTooltipBody presentation={presentation} />
</HoverTooltip>
+3 -4
View File
@@ -269,14 +269,13 @@ export default {
'machine.health.status.elevated': 'Elevated — new agents may run slower',
'machine.health.status.high': 'High pressure — avoid spawning more here',
'machine.health.status.unknown': 'Metrics unavailable',
'machine.health.metric.cpu': 'CPU across all cores',
'machine.health.metric.cpuWithCount': 'CPU across all {n} cores',
'machine.health.metric.ram': 'RAM in use',
'machine.health.metric.cpu': 'CPU',
'machine.health.metric.ram': 'RAM',
'machine.health.tooltip.load': 'Run queue (1 min): {value}',
'machine.health.tooltip.loadShort': 'Load (1m)',
'machine.health.tooltip.uptimeShort': 'Uptime',
'machine.health.uptimeCompact': 'up {value}',
'machine.health.tooltip.hint': 'Updated every ~20s from the runner on this machine.',
'machine.health.tooltip.hint': 'Updated every ~20s from the runner on this machine',
'machine.health.aria.cpu': 'CPU {n} percent',
'machine.health.aria.ram': 'RAM {n} percent',
'machine.health.aria.unknown': 'Machine health unavailable',
+2 -3
View File
@@ -273,14 +273,13 @@ export default {
'machine.health.status.elevated': '偏高 — 新代理可能变慢',
'machine.health.status.high': '高压 — 避免在此继续启动',
'machine.health.status.unknown': '指标不可用',
'machine.health.metric.cpu': '全部核心的 CPU',
'machine.health.metric.cpuWithCount': '全部 {n} 个核心的 CPU',
'machine.health.metric.cpu': 'CPU占用',
'machine.health.metric.ram': '内存占用',
'machine.health.tooltip.load': '运行队列 (1 分钟): {value}',
'machine.health.tooltip.loadShort': '负载 (1 分钟)',
'machine.health.tooltip.uptimeShort': '运行时间',
'machine.health.uptimeCompact': '已运行 {value}',
'machine.health.tooltip.hint': '约每 20 秒由该机器上的 runner 更新',
'machine.health.tooltip.hint': '约每 20 秒由该机器上的 runner 更新',
'machine.health.aria.cpu': 'CPU {n}%',
'machine.health.aria.ram': '内存 {n}%',
'machine.health.aria.unknown': '机器健康数据不可用',
-17
View File
@@ -4,7 +4,6 @@ import {
presentMachineHealth,
resolveMachineOsLabel,
shouldShowMachineHostSubtitle,
getCpuMetricTooltipLabel,
} from './machineHealth'
describe('presentMachineHealth', () => {
@@ -75,22 +74,6 @@ describe('resolveMachineOsLabel', () => {
})
})
describe('getCpuMetricTooltipLabel', () => {
const t = (key: string, params?: Record<string, string | number>) => {
if (key === 'machine.health.metric.cpuWithCount') {
return `CPU across all ${params?.n} cores`
}
return 'CPU across all cores'
}
it('includes core count when known', () => {
expect(getCpuMetricTooltipLabel(6, t)).toBe('CPU across all 6 cores')
})
it('falls back when core count is missing', () => {
expect(getCpuMetricTooltipLabel(undefined, t)).toBe('CPU across all cores')
})
})
describe('shouldShowMachineHostSubtitle', () => {
it('hides host when it matches the display label', () => {
expect(shouldShowMachineHostSubtitle('Teemo', 'Teemo')).toBe(false)
-10
View File
@@ -140,16 +140,6 @@ export function presentMachineHealth(
}
}
export function getCpuMetricTooltipLabel(
cpuCount: number | undefined,
t: (key: string, params?: Record<string, string | number>) => string
): string {
if (cpuCount !== undefined && cpuCount > 0) {
return t('machine.health.metric.cpuWithCount', { n: cpuCount })
}
return t('machine.health.metric.cpu')
}
export function getMachinePlatform(machine: Machine | null | undefined): string | null {
return machine?.metadata?.platform ?? null
}