From 67fa855b6c0dfb871defd1d20296cd2d1df22873 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 29 Aug 2026 14:15:45 -0700
Subject: [PATCH] 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.
---
.../src/components/WorktreeJumpPalette.tsx | 39 +++++-----
...jump-palette-interleaved-sections.test.tsx | 75 +++++++++++++++----
.../worktree-palette-create-action.test.ts | 3 +-
.../src/lib/worktree-palette-create-action.ts | 5 +-
4 files changed, 87 insertions(+), 35 deletions(-)
diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx
index f18ba555d68..4e39baf2bea 100644
--- a/src/renderer/src/components/WorktreeJumpPalette.tsx
+++ b/src/renderer/src/components/WorktreeJumpPalette.tsx
@@ -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 (
-
{
+ const previousIndex = selectionItemIds.indexOf(renderKey)
+ flushSync(() => entry.onSeeMore?.())
+ const expandedItemId = Array.from(
+ listRef.current?.querySelectorAll('[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'
+ )}
>
{entry.label}
{entry.onSeeMore ? (
- {
- event.preventDefault()
- event.stopPropagation()
- entry.onSeeMore?.()
- inputRef.current?.focus()
- }}
- >
+
{translate('worktreeJumpPalette.seeMore', 'See more')}
-
+
) : null}
-
+
)
}
diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx
index 854412b5107..f52bf725a5a 100644
--- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx
+++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx
@@ -57,18 +57,33 @@ vi.mock('@/components/ui/command', async () => {
return {
Command: ({ children }: { children: React.ReactNode }) => {children}
,
CommandGroup: ({ children }: { children: React.ReactNode }) => {children}
,
- CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
- open ? {children}
: null,
- CommandInput: ({
- value,
- onValueChange
+ CommandDialog: ({
+ children,
+ open,
+ commandProps
}: {
- value?: string
- onValueChange?: (next: string) => void
- }) => {
+ children: React.ReactNode
+ open?: boolean
+ commandProps?: { value?: string }
+ }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+ CommandInput: React.forwardRef(function CommandInput(
+ {
+ value,
+ onValueChange
+ }: {
+ value?: string
+ onValueChange?: (next: string) => void
+ },
+ ref: React.ForwardedRef
+ ) {
setCommandQuery = onValueChange ?? null
- return {}} />
- },
+ return {}} />
+ }),
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef
@@ -82,8 +97,22 @@ vi.mock('@/components/ui/command', async () => {
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
{children}
),
- CommandItem: ({ children, value }: { children: React.ReactNode; value?: string }) => (
-
+ CommandItem: ({
+ children,
+ value,
+ onSelect
+ }: {
+ children: React.ReactNode
+ value?: string
+ onSelect?: () => void
+ }) => (
+
{children}
)
@@ -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('[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')
})
diff --git a/src/renderer/src/lib/worktree-palette-create-action.test.ts b/src/renderer/src/lib/worktree-palette-create-action.test.ts
index 408d6227c45..8f4667a043e 100644
--- a/src/renderer/src/lib/worktree-palette-create-action.test.ts
+++ b/src/renderer/src/lib/worktree-palette-create-action.test.ts
@@ -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'
diff --git a/src/renderer/src/lib/worktree-palette-create-action.ts b/src/renderer/src/lib/worktree-palette-create-action.ts
index f054b5d0f52..392bcddc33f 100644
--- a/src/renderer/src/lib/worktree-palette-create-action.ts
+++ b/src/renderer/src/lib/worktree-palette-create-action.ts
@@ -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.