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 ? ( - + ) : 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 }) => ( - ) @@ -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.