Fix Cmd+J create workspace selection (#2800)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-25 15:27:01 -07:00 committed by GitHub
parent c7b6476708
commit 8fbc3eecd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 118 additions and 32 deletions

View File

@ -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<PaletteListEntry[]>(() => {
@ -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(() => {

View File

@ -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({

View File

@ -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,