From 77f94ef738becb020ff9725bb8e859ac59bfedb7 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 19 Jul 2026 12:24:09 +0800 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: HAPI Co-authored-by: Debian Co-authored-by: Cursor --- web/src/components/HoverTooltip.tsx | 21 ++- .../components/MachineGroupHeader.test.tsx | 26 ++-- web/src/components/MachineGroupHeader.tsx | 66 ++------- .../MachineHealthIndicator.test.tsx | 30 ++++- web/src/components/MachineHealthIndicator.tsx | 125 +++++++++++++++--- web/src/lib/locales/en.ts | 7 +- web/src/lib/locales/zh-CN.ts | 5 +- web/src/lib/machineHealth.test.ts | 17 --- web/src/lib/machineHealth.ts | 10 -- 9 files changed, 186 insertions(+), 121 deletions(-) diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx index d24744fe..fbbecc62 100644 --- a/web/src/components/HoverTooltip.tsx +++ b/web/src/components/HoverTooltip.tsx @@ -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 + 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 ( - + {props.target} @@ -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' )} diff --git a/web/src/components/MachineGroupHeader.test.tsx b/web/src/components/MachineGroupHeader.test.tsx index 1193cbeb..c917da71 100644 --- a/web/src/components/MachineGroupHeader.test.tsx +++ b/web/src/components/MachineGroupHeader.test.tsx @@ -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( {}} + onToggle={onToggle} machine={machine} healthPresentation={{ metrics: [ @@ -44,13 +45,21 @@ describe('MachineGroupHeader', () => { ) - 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( { ) - expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy() + expect(screen.getByTitle('proxmox')).toBeTruthy() + expect(screen.getByText('1h 54m')).toBeTruthy() }) }) diff --git a/web/src/components/MachineGroupHeader.tsx b/web/src/components/MachineGroupHeader.tsx index 6f8e5d5f..8301fbe0 100644 --- a/web/src/components/MachineGroupHeader.tsx +++ b/web/src/components/MachineGroupHeader.tsx @@ -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, - 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 ( - {hasHealth ? ( ) : null} ({props.sessionCount}) - + ) } diff --git a/web/src/components/MachineHealthIndicator.test.tsx b/web/src/components/MachineHealthIndicator.test.tsx index 677846f4..b29fd2bd 100644 --- a/web/src/components/MachineHealthIndicator.test.tsx +++ b/web/src/components/MachineHealthIndicator.test.tsx @@ -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', () => { ) - 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', () => { diff --git a/web/src/components/MachineHealthIndicator.tsx b/web/src/components/MachineHealthIndicator.tsx index 457039d4..1e435a0c 100644 --- a/web/src/components/MachineHealthIndicator.tsx +++ b/web/src/components/MachineHealthIndicator.tsx @@ -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 ( - + {props.label}