From 8fbc3eecd09baa69f16ca0fbb0777bcaee2d45cc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 25 May 2026 15:27:01 -0700 Subject: [PATCH] Fix Cmd+J create workspace selection (#2800) Co-authored-by: Orca --- .../src/components/WorktreeJumpPalette.tsx | 17 ++-- .../worktree-palette-create-action.test.ts | 91 +++++++++++++++---- .../src/lib/worktree-palette-create-action.ts | 42 +++++++-- 3 files changed, 118 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 2396dab3b..63a861aee 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -34,6 +34,7 @@ import { CREATE_WORKTREE_ITEM_ID, createWorktreePaletteRequestGuard, getNextWorktreePaletteSelection, + getWorktreePaletteSelectionItemIds, getWorktreePaletteCreateActionState } from '@/lib/worktree-palette-create-action' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' @@ -571,16 +572,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [paletteSections] ) - const selectableItemIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems]) - const { createWorktreeName, showCreateAction } = useMemo( () => getWorktreePaletteCreateActionState({ canCreateWorktree, - query: deferredQuery, - selectableItemIds + query: deferredQuery }), - [canCreateWorktree, deferredQuery, selectableItemIds] + [canCreateWorktree, deferredQuery] ) const listEntries = useMemo(() => { @@ -651,6 +649,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return entries }, [hasQuery, paletteSections, showCreateAction, worktreeItems.length]) + const selectionItemIds = useMemo( + () => getWorktreePaletteSelectionItemIds(listEntries), + [listEntries] + ) + // Why: empty-state / "has any worktrees?" uses the full visible list // (including current) so the palette never claims to be empty just // because the only visible worktree is the currently active one. @@ -721,7 +724,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const nextSelectedItemId = getNextWorktreePaletteSelection({ currentSelectedItemId: selectedItemId, queryChanged, - selectableItemIds, + selectableItemIds: selectionItemIds, showCreateAction }) if (queryChanged) { @@ -733,7 +736,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (nextSelectedItemId !== selectedItemId) { setSelectedItemId(nextSelectedItemId) } - }, [deferredQuery, selectedItemId, showCreateAction, visible, selectableItemIds]) + }, [deferredQuery, selectedItemId, showCreateAction, visible, selectionItemIds]) const focusFallbackSurface = useCallback(() => { requestAnimationFrame(() => { 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 9f1d53121..57ef4b26b 100644 --- a/src/renderer/src/lib/worktree-palette-create-action.test.ts +++ b/src/renderer/src/lib/worktree-palette-create-action.test.ts @@ -3,60 +3,71 @@ import { CREATE_WORKTREE_ITEM_ID, createWorktreePaletteRequestGuard, getNextWorktreePaletteSelection, + getWorktreePaletteSelectionItemIds, getWorktreePaletteCreateActionState } from './worktree-palette-create-action' describe('worktree-palette-create-action', () => { - it('shows create for typed queries with workspace matches but selects the first real row', () => { + it('shows create for typed queries with workspace matches but selects the first workspace row', () => { const state = getWorktreePaletteCreateActionState({ canCreateWorktree: true, - query: 'feature', - selectableItemIds: ['worktree:one'] + query: 'feature' }) expect(state).toEqual({ createWorktreeName: 'feature', - showCreateAction: true, - shouldDefaultToCreate: false + showCreateAction: true }) expect( getNextWorktreePaletteSelection({ currentSelectedItemId: '', queryChanged: true, - selectableItemIds: ['worktree:one'], + selectableItemIds: ['worktree:one', CREATE_WORKTREE_ITEM_ID, 'settings:provider'], showCreateAction: state.showCreateAction }) ).toBe('worktree:one') }) - it('shows create for typed queries with browser-only matches but selects the browser row', () => { + it('selects create when it appears before actions, settings, and browser rows', () => { const state = getWorktreePaletteCreateActionState({ canCreateWorktree: true, - query: 'localhost', - selectableItemIds: ['browser-page:one'] + query: 'opencode-issue' }) expect(state.showCreateAction).toBe(true) - expect(state.shouldDefaultToCreate).toBe(false) expect( getNextWorktreePaletteSelection({ currentSelectedItemId: '', queryChanged: true, - selectableItemIds: ['browser-page:one'], + selectableItemIds: [ + CREATE_WORKTREE_ITEM_ID, + 'settings:ai-provider-accounts', + 'quick-action:new-terminal', + 'browser-page:one' + ], showCreateAction: state.showCreateAction }) - ).toBe('browser-page:one') + ).toBe(CREATE_WORKTREE_ITEM_ID) + }) + + it('selects create when it appears before a browser-only match', () => { + expect( + getNextWorktreePaletteSelection({ + currentSelectedItemId: '', + queryChanged: true, + selectableItemIds: [CREATE_WORKTREE_ITEM_ID, 'browser-page:one'], + showCreateAction: true + }) + ).toBe(CREATE_WORKTREE_ITEM_ID) }) it('defaults to create for typed queries with no real matches', () => { const state = getWorktreePaletteCreateActionState({ canCreateWorktree: true, - query: 'new-workspace', - selectableItemIds: [] + query: 'new-workspace' }) expect(state.showCreateAction).toBe(true) - expect(state.shouldDefaultToCreate).toBe(true) expect( getNextWorktreePaletteSelection({ currentSelectedItemId: '', @@ -67,6 +78,28 @@ describe('worktree-palette-create-action', () => { ).toBe(CREATE_WORKTREE_ITEM_ID) }) + it('returns empty selection when no create action or rows are available', () => { + expect( + getNextWorktreePaletteSelection({ + currentSelectedItemId: '', + queryChanged: true, + selectableItemIds: [], + showCreateAction: false + }) + ).toBe('') + }) + + it('does not keep create selected after the create row disappears', () => { + expect( + getNextWorktreePaletteSelection({ + currentSelectedItemId: CREATE_WORKTREE_ITEM_ID, + queryChanged: false, + selectableItemIds: ['settings:ai-provider-accounts'], + showCreateAction: false + }) + ).toBe('settings:ai-provider-accounts') + }) + it('moves selection back to the first real row when the query changes after manual create selection', () => { expect( getNextWorktreePaletteSelection({ @@ -93,8 +126,7 @@ describe('worktree-palette-create-action', () => { expect( getWorktreePaletteCreateActionState({ canCreateWorktree: false, - query: 'new-workspace', - selectableItemIds: [] + query: 'new-workspace' }).showCreateAction ).toBe(false) }) @@ -103,12 +135,33 @@ describe('worktree-palette-create-action', () => { expect( getWorktreePaletteCreateActionState({ canCreateWorktree: true, - query: ' ', - selectableItemIds: [] + query: ' ' }).showCreateAction ).toBe(false) }) + it('derives selection ids from rendered entries while skipping headers and hints', () => { + expect( + getWorktreePaletteSelectionItemIds([ + { id: '__header_worktrees__', type: 'section-header' }, + { id: 'worktree:one', type: 'worktree' }, + { id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }, + { id: '__hint_worktree_cap__', type: 'hint' }, + { id: '__header_actions_settings__', type: 'section-header' }, + { id: 'settings:ai-provider-accounts', type: 'settings' }, + { id: 'quick-action:new-terminal', type: 'quick-action' }, + { id: '__header_browser__', type: 'section-header' }, + { id: 'browser-page:one', type: 'browser-page' } + ]) + ).toEqual([ + 'worktree:one', + CREATE_WORKTREE_ITEM_ID, + 'settings:ai-provider-accounts', + 'quick-action:new-terminal', + 'browser-page:one' + ]) + }) + it('falls back deterministically when the selected row disappears', () => { expect( getNextWorktreePaletteSelection({ diff --git a/src/renderer/src/lib/worktree-palette-create-action.ts b/src/renderer/src/lib/worktree-palette-create-action.ts index 8c6b091bb..199881b2e 100644 --- a/src/renderer/src/lib/worktree-palette-create-action.ts +++ b/src/renderer/src/lib/worktree-palette-create-action.ts @@ -3,27 +3,57 @@ export const CREATE_WORKTREE_ITEM_ID = '__create_worktree__' export type WorktreePaletteCreateActionState = { createWorktreeName: string showCreateAction: boolean - shouldDefaultToCreate: boolean } export function getWorktreePaletteCreateActionState({ canCreateWorktree, - query, - selectableItemIds + query }: { canCreateWorktree: boolean query: string - selectableItemIds: readonly string[] }): WorktreePaletteCreateActionState { const createWorktreeName = query.trim() const showCreateAction = canCreateWorktree && createWorktreeName.length > 0 return { createWorktreeName, - showCreateAction, - shouldDefaultToCreate: showCreateAction && selectableItemIds.length === 0 + showCreateAction } } +type WorktreePaletteSelectionCandidateEntry = { + id: string + type: string +} + +type WorktreePaletteSelectableEntryType = + | 'worktree' + | 'create-worktree' + | 'settings' + | 'quick-action' + | 'browser-page' + +export function isSelectableWorktreePaletteEntry( + entry: WorktreePaletteSelectionCandidateEntry +): entry is WorktreePaletteSelectionCandidateEntry & { + type: WorktreePaletteSelectableEntryType +} { + return ( + entry.type === 'worktree' || + entry.type === 'create-worktree' || + entry.type === 'settings' || + entry.type === 'quick-action' || + entry.type === 'browser-page' + ) +} + +export function getWorktreePaletteSelectionItemIds< + T extends WorktreePaletteSelectionCandidateEntry +>(entries: readonly T[]): string[] { + // Why: keyboard focus should mirror rendered order, including synthetic + // action rows, while skipping headers and explanatory hint rows. + return entries.filter(isSelectableWorktreePaletteEntry).map((entry) => entry.id) +} + export function getNextWorktreePaletteSelection({ currentSelectedItemId, queryChanged,