fix(sidebar): make notice host glyphs accessible

This commit is contained in:
Brennan Benson
2026-08-20 01:32:15 -07:00
parent 5b8e622a86
commit f6c9f7dac8
9 changed files with 94 additions and 13 deletions
@@ -6,8 +6,6 @@ import { cn } from '@/lib/utils'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { ProjectOptionDetail } from './ProjectComboboxRow'
import { translate } from '@/i18n/i18n'
// Re-exported from its shared home so both the composer and the sidebar draw
// one host glyph vocabulary.
import { HostRowIcon } from '../host-row-icon'
export { HostRowIcon }
@@ -159,7 +159,11 @@ export default function ImportedWorktreesVisibilityLine({
{hostContextLabel && placement !== 'pinned-fallback' ? (
<span className="inline-flex min-w-0 shrink items-center gap-1">
{hostContextHostId ? (
<NoticeHostGlyph hostId={hostContextHostId} hostLabel={hostContextLabel} />
<NoticeHostGlyph
hostId={hostContextHostId}
hostLabel={hostContextLabel}
keyboardFocusable
/>
) : null}
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
{hostContextLabel}
@@ -103,7 +103,11 @@ export default function NewExternalWorktreesInboxLine({
{hostContextLabel ? (
<span className="inline-flex min-w-0 shrink items-center gap-1">
{hostContextHostId ? (
<NoticeHostGlyph hostId={hostContextHostId} hostLabel={hostContextLabel} />
<NoticeHostGlyph
hostId={hostContextHostId}
hostLabel={hostContextLabel}
keyboardFocusable={false}
/>
) : null}
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
{hostContextLabel}
@@ -1,16 +1,19 @@
// @vitest-environment happy-dom
/**
* Notice rows reuse the worktree card's host indicator so one project's rows
* read the same way wherever they appear: a server glyph with a "Project on …"
* tooltip, nothing at all for local.
* Notice rows reuse one host vocabulary: monitor for local, server for remote,
* with the worktree card's "Project on …" tooltip copy.
*/
import { act, type ReactNode } from 'react'
import { act, cloneElement, type ReactElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NoticeHostGlyph from './NoticeHostGlyph'
import en from '../../i18n/locales/en.json'
import es from '../../i18n/locales/es.json'
import ja from '../../i18n/locales/ja.json'
import ko from '../../i18n/locales/ko.json'
import zh from '../../i18n/locales/zh.json'
const runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
@@ -20,7 +23,8 @@ vi.mock('@/store', () => ({
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactElement<{ 'data-testid'?: string }> }) =>
cloneElement(children, { 'data-testid': 'tooltip-trigger' }),
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-testid="tooltip">{children}</span>
)
@@ -28,13 +32,23 @@ vi.mock('@/components/ui/tooltip', () => ({
const roots: Root[] = []
async function render(hostId: string, hostLabel = 'openclaw'): Promise<HTMLDivElement> {
async function render(
hostId: string,
hostLabel = 'openclaw',
keyboardFocusable = false
): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<NoticeHostGlyph hostId={hostId as never} hostLabel={hostLabel} />)
root.render(
<NoticeHostGlyph
hostId={hostId as never}
hostLabel={hostLabel}
keyboardFocusable={keyboardFocusable}
/>
)
})
return container
}
@@ -86,6 +100,23 @@ describe('NoticeHostGlyph', () => {
)
})
it('makes a passive row glyph keyboard reachable with an accessible name', async () => {
const container = await render('ssh:openclaw-target', 'openclaw', true)
const trigger = container.querySelector('[data-testid="tooltip-trigger"]')
expect(trigger?.getAttribute('tabindex')).toBe('0')
expect(trigger?.getAttribute('role')).toBe('img')
expect(trigger?.getAttribute('aria-label')).toBe('Project on SSH host openclaw')
})
it('does not add a nested tab stop when the glyph is inside a button', async () => {
const container = await render('ssh:openclaw-target')
expect(
container.querySelector('[data-testid="tooltip-trigger"]')?.hasAttribute('tabindex')
).toBe(false)
})
it('draws one glyph vocabulary: a monitor for local, a server for remote', async () => {
const local = await render('local', 'Local Mac')
const remote = await render('ssh:openclaw-target')
@@ -111,4 +142,16 @@ describe('NoticeHostGlyph', () => {
runtimeHostProject: 'Project on {{hostName}}'
})
})
it.each(Object.entries({ es, ja, ko, zh }))(
'keeps its copy in the %s catalog',
(_locale, catalog) => {
expect(catalog.auto.components.sidebar.NoticeHostGlyph).toMatchObject({
hostDisconnected: expect.stringContaining('{{hostName}}'),
sshHostProject: expect.stringContaining('{{hostName}}'),
localHostProject: expect.any(String),
runtimeHostProject: expect.stringContaining('{{hostName}}')
})
}
)
})
@@ -9,6 +9,7 @@ import { translate } from '@/i18n/i18n'
type NoticeHostGlyphProps = {
hostId: ExecutionHostId
hostLabel: string
keyboardFocusable: boolean
}
/**
@@ -21,7 +22,8 @@ type NoticeHostGlyphProps = {
*/
export default function NoticeHostGlyph({
hostId,
hostLabel
hostLabel,
keyboardFocusable
}: NoticeHostGlyphProps): React.JSX.Element | null {
const host = parseExecutionHostId(hostId)
const isDisconnected = useAppStore((s) => {
@@ -61,7 +63,13 @@ export default function NoticeHostGlyph({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center" data-notice-host-kind={host.kind}>
<span
aria-label={keyboardFocusable ? tooltip : undefined}
className="inline-flex shrink-0 items-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
data-notice-host-kind={host.kind}
role={keyboardFocusable ? 'img' : undefined}
tabIndex={keyboardFocusable ? 0 : undefined}
>
<HostRowIcon
hostId={hostId}
className={`size-3 shrink-0 ${
+6
View File
@@ -5059,6 +5059,12 @@
"5e1b8d3f62": "Ocultar worktrees externos permanentemente",
"c3e8a1f4b2": "No volver a mostrar"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} está desconectado",
"sshHostProject": "Proyecto en el host SSH {{hostName}}",
"localHostProject": "Proyecto en este host",
"runtimeHostProject": "Proyecto en {{hostName}}"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "No se pudieron mantener ocultos los worktrees externos. Inténtalo de nuevo.",
"b7e4d1a062": "No se pudieron importar los worktrees externos. Inténtalo de nuevo.",
+6
View File
@@ -5059,6 +5059,12 @@
"5e1b8d3f62": "外部ワークツリーを完全に非表示にする",
"c3e8a1f4b2": "今後表示しない"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} が切断されました",
"sshHostProject": "SSH ホスト {{hostName}} 上のプロジェクト",
"localHostProject": "このホスト上のプロジェクト",
"runtimeHostProject": "{{hostName}} 上のプロジェクト"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "外部ワークツリーを非表示のままにできませんでした。もう一度お試しください。",
"b7e4d1a062": "外部ワークツリーをインポートできませんでした。もう一度お試しください。",
+6
View File
@@ -5061,6 +5061,12 @@
"5e1b8d3f62": "외부 워크트리를 영구적으로 숨기기",
"c3e8a1f4b2": "다시 표시하지 않기"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} 연결 끊김",
"sshHostProject": "SSH 호스트 {{hostName}}의 프로젝트",
"localHostProject": "이 호스트의 프로젝트",
"runtimeHostProject": "{{hostName}}의 프로젝트"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "외부 워크트리를 숨김 상태로 유지할 수 없습니다. 다시 시도하세요.",
"b7e4d1a062": "외부 워크트리를 가져올 수 없습니다. 다시 시도하세요.",
+6
View File
@@ -5071,6 +5071,12 @@
"5e1b8d3f62": "永久隐藏外部工作树",
"c3e8a1f4b2": "不再显示"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} 已断开连接",
"sshHostProject": "SSH 主机 {{hostName}} 上的项目",
"localHostProject": "此主机上的项目",
"runtimeHostProject": "{{hostName}} 上的项目"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "无法保持外部工作树隐藏。请重试。",
"b7e4d1a062": "无法导入外部工作树。请重试。",