Make worktree palette hint rows keyboard-clickable (#17272)

* Make worktree palette hint rows keyboard-clickable

Hint entries like "See more" are now CommandItems that can be navigated with arrow keys and activated with Enter, instead of being non-interactive divs. This allows keyboard-only users to access the expand actions without mouse interaction.

* Make worktree palette "See more" keyboard-navigable

Preserve cursor position when expanding via keyboard: auto-select the first
newly revealed item at the previous index and restore input focus.
This commit is contained in:
Jinjing
2026-08-29 14:15:45 -07:00
committed by GitHub
parent 2dfaa676d8
commit 67fa855b6c
4 changed files with 87 additions and 35 deletions
@@ -8,6 +8,7 @@ import React, {
useRef,
useState
} from 'react'
import { flushSync } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -42,7 +43,6 @@ import {
CommandEmpty,
CommandItem
} from '@/components/ui/command'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
@@ -3258,30 +3258,33 @@ function WorktreeJumpPaletteContent({
}
if (entry.type === 'hint') {
// Why: plain div (not CommandItem) so cmdk can't select it; arrow keys skip it via selectableItems.
return (
<div
<CommandItem
key={renderKey}
className="mx-0.5 mt-1 flex items-center gap-2 px-3 py-1.5 text-[12px] text-muted-foreground"
value={renderKey}
onSelect={() => {
const previousIndex = selectionItemIds.indexOf(renderKey)
flushSync(() => entry.onSeeMore?.())
const expandedItemId = Array.from(
listRef.current?.querySelectorAll<HTMLElement>('[cmdk-item]') ?? []
)[previousIndex]?.getAttribute('data-value')
if (expandedItemId) {
setSelectedItemId(expandedItemId)
}
inputRef.current?.focus()
}}
className={cn(
JUMP_PALETTE_ITEM_CLASSNAME,
'mt-1 min-h-0 gap-2 py-1.5 text-[12px] text-muted-foreground'
)}
>
<span className="truncate">{entry.label}</span>
{entry.onSeeMore ? (
<Button
type="button"
variant="outline"
size="xs"
className="h-6 shrink-0 px-2 text-xs font-medium text-foreground hover:bg-accent"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
entry.onSeeMore?.()
inputRef.current?.focus()
}}
>
<span className="h-6 shrink-0 rounded-md border border-input bg-background px-2 text-xs font-medium leading-6 text-foreground">
{translate('worktreeJumpPalette.seeMore', 'See more')}
</Button>
</span>
) : null}
</div>
</CommandItem>
)
}
@@ -57,18 +57,33 @@ vi.mock('@/components/ui/command', async () => {
return {
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open ? <div data-command-dialog="true">{children}</div> : null,
CommandInput: ({
value,
onValueChange
CommandDialog: ({
children,
open,
commandProps
}: {
value?: string
onValueChange?: (next: string) => void
}) => {
children: React.ReactNode
open?: boolean
commandProps?: { value?: string }
}) =>
open ? (
<div data-command-dialog="true" data-command-value={commandProps?.value ?? ''}>
{children}
</div>
) : null,
CommandInput: React.forwardRef(function CommandInput(
{
value,
onValueChange
}: {
value?: string
onValueChange?: (next: string) => void
},
ref: React.ForwardedRef<HTMLInputElement>
) {
setCommandQuery = onValueChange ?? null
return <input data-command-input="true" value={value} onChange={() => {}} />
},
return <input ref={ref} data-command-input="true" value={value} onChange={() => {}} />
}),
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef<HTMLDivElement>
@@ -82,8 +97,22 @@ vi.mock('@/components/ui/command', async () => {
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
<div data-command-empty="true">{children}</div>
),
CommandItem: ({ children, value }: { children: React.ReactNode; value?: string }) => (
<button data-command-item={value ?? ''} type="button">
CommandItem: ({
children,
value,
onSelect
}: {
children: React.ReactNode
value?: string
onSelect?: () => void
}) => (
<button
cmdk-item=""
data-value={value ?? ''}
data-command-item={value ?? ''}
type="button"
onClick={onSelect}
>
{children}
</button>
)
@@ -534,6 +563,13 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
btn.textContent?.includes('See more')
)
expect(seeMoreBtn).toBeDefined()
const initialItemIds = Array.from(testContainer.querySelectorAll('[cmdk-item]')).map((item) =>
item.getAttribute('data-value')
)
const seeMoreIndex = initialItemIds.indexOf('__hint_worktree_overflow__')
expect(seeMoreIndex).toBeGreaterThan(0)
const input = testContainer.querySelector<HTMLInputElement>('[data-command-input="true"]')
input?.focus()
await act(async () => {
seeMoreBtn?.click()
@@ -541,9 +577,20 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
await flushEffects()
// After expanding by 20: 30 worktrees are rendered, 5 more
const renderedItems = testContainer.querySelectorAll('[data-command-item]')
const renderedItems = testContainer.querySelectorAll('[data-command-item^="worktree:"]')
expect(renderedItems).toHaveLength(30)
expect(testContainer.textContent).toContain('5 more')
const firstRevealedItemId = Array.from(testContainer.querySelectorAll('[cmdk-item]'))[
seeMoreIndex
]?.getAttribute('data-value')
expect(firstRevealedItemId).toMatch(/^worktree:/)
expect(firstRevealedItemId).not.toBe(initialItemIds[0])
expect(
testContainer
.querySelector('[data-command-dialog="true"]')
?.getAttribute('data-command-value')
).toBe(firstRevealedItemId)
expect(document.activeElement).toBe(input)
// Click again: 30 + 20 = 50 (all 35 fit), hint disappears
const seeMoreBtn2 = Array.from(testContainer.querySelectorAll('button')).find((btn) =>
@@ -554,7 +601,7 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
})
await flushEffects()
const renderedItemsAll = testContainer.querySelectorAll('[data-command-item]')
const renderedItemsAll = testContainer.querySelectorAll('[data-command-item^="worktree:"]')
expect(renderedItemsAll).toHaveLength(35)
expect(testContainer.textContent).not.toContain('more')
})
@@ -182,7 +182,7 @@ describe('worktree-palette-create-action', () => {
})
})
it('derives selection ids from rendered entries while skipping headers and hints', () => {
it('derives selection ids from rendered entries while skipping headers', () => {
expect(
getWorktreePaletteSelectionItemIds([
{ id: '__header_worktrees__', type: 'section-header' },
@@ -198,6 +198,7 @@ describe('worktree-palette-create-action', () => {
).toEqual([
'worktree:one',
CREATE_WORKTREE_ITEM_ID,
'__hint_worktree_cap__',
'settings:ai-provider-accounts',
'quick-action:new-terminal',
'browser-page:one'
@@ -66,7 +66,8 @@ const SELECTABLE_ENTRY_TYPES = [
'browser-page',
'workspace-tab',
'simulator-tab',
'project-target'
'project-target',
'hint'
] as const
type WorktreePaletteSelectableEntryType = (typeof SELECTABLE_ENTRY_TYPES)[number]
@@ -85,7 +86,7 @@ export function getWorktreePaletteSelectionItemIds<
T extends WorktreePaletteSelectionCandidateEntry
>(entries: readonly T[], renderKeys: readonly string[] = []): string[] {
// Why: keyboard focus should mirror rendered order, including synthetic
// action rows, while skipping headers and explanatory hint rows.
// action rows, while skipping only section headers.
// Why renderKeys wins: rows render under de-duplicated keys, so naming the bare
// id here would leave a duplicate row absent from the list the `includes` check
// above consults — arrowing onto it would snap the highlight back to the top.