diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index b2fe73ec..45cdccee 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -38,8 +38,6 @@ import { import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' -type PaletteScope = 'worktrees' | 'browser-tabs' - type WorktreePaletteItem = { id: string type: 'worktree' @@ -53,16 +51,22 @@ type BrowserPaletteItem = { result: BrowserPaletteSearchResult } +type SectionHeader = { + id: string + type: 'section-header' + label: string +} + type PaletteItem = WorktreePaletteItem | BrowserPaletteItem +type PaletteListEntry = PaletteItem | SectionHeader + type BrowserSelection = { worktree: Worktree workspace: BrowserWorkspace page: BrowserPage } -const SCOPE_ORDER: PaletteScope[] = ['worktrees', 'browser-tabs'] - function HighlightedText({ text, matchRange @@ -102,12 +106,6 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme ) } -function nextScope(scope: PaletteScope, direction: 1 | -1): PaletteScope { - const index = SCOPE_ORDER.indexOf(scope) - const nextIndex = (index + direction + SCOPE_ORDER.length) % SCOPE_ORDER.length - return SCOPE_ORDER[nextIndex] -} - function findBrowserSelection( pageId: string, workspaceId: string, @@ -148,7 +146,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const [query, setQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') - const [scope, setScope] = useState('worktrees') const [selectedItemId, setSelectedItemId] = useState('') const previousWorktreeIdRef = useRef(null) const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal') @@ -157,7 +154,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const wasVisibleRef = useRef(false) const skipRestoreFocusRef = useRef(false) const prevQueryRef = useRef('') - const prevScopeRef = useRef('worktrees') const listRef = useRef(null) useEffect(() => { @@ -270,19 +266,56 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [browserMatches] ) - const visibleItems = useMemo(() => { - if (scope === 'browser-tabs') { - return browserItems + // Why: merging both result sets into a single list avoids the tab-switching + // UX that forces users to guess which scope their target lives in. Section + // headers follow the VSCode "AnythingQuickAccess" pattern but we only render + // them when both sections have content — a lone "WORKTREES" or "BROWSER TABS" + // label above a single list is redundant noise. + // + // Why cap only the browser section on empty query: Cmd+J is worktree-first, + // and power users arrow-key through the recent-worktree list. Truncating + // worktrees would hide any past the cap until the user types. Instead we + // keep worktrees unbounded and cap the secondary Browser Tabs section to a + // small preview on open — users type to see more, or scroll past worktrees. + const listEntries = useMemo(() => { + const entries: PaletteListEntry[] = [] + const bothSectionsPopulated = worktreeItems.length > 0 && browserItems.length > 0 + const hasQuery = debouncedQuery.trim().length > 0 + const EMPTY_QUERY_BROWSER_PREVIEW = 3 + + const visibleWorktreeItems = worktreeItems + const visibleBrowserItems = + !hasQuery && bothSectionsPopulated + ? browserItems.slice(0, EMPTY_QUERY_BROWSER_PREVIEW) + : browserItems + const showHeaders = bothSectionsPopulated + if (visibleWorktreeItems.length > 0) { + if (showHeaders) { + entries.push({ id: '__header_worktrees__', type: 'section-header', label: 'Worktrees' }) + } + entries.push(...visibleWorktreeItems) } - return worktreeItems - }, [browserItems, scope, worktreeItems]) + if (visibleBrowserItems.length > 0) { + if (showHeaders) { + entries.push({ + id: '__header_browser__', + type: 'section-header', + label: 'Browser Tabs' + }) + } + entries.push(...visibleBrowserItems) + } + return entries + }, [worktreeItems, browserItems, debouncedQuery]) + + const selectableItems = useMemo( + () => listEntries.filter((e): e is PaletteItem => e.type !== 'section-header'), + [listEntries] + ) const createWorktreeName = debouncedQuery.trim() const showCreateAction = - scope === 'worktrees' && - canCreateWorktree && - createWorktreeName.length > 0 && - worktreeItems.length === 0 + canCreateWorktree && createWorktreeName.length > 0 && worktreeItems.length === 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 const hasAnyWorktrees = sortedWorktrees.length > 0 @@ -291,9 +324,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { useEffect(() => { if (visible && !wasVisibleRef.current) { - // Why: the palette now supports multiple scopes, but Cmd+J still has a - // worktree-first contract. Reset to that scope on every open so browser - // exploration remains opt-in rather than sticky across sessions. previousWorktreeIdRef.current = activeWorktreeId previousActiveTabTypeRef.current = activeTabType previousBrowserPageIdRef.current = @@ -313,8 +343,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { : 'webview' skipRestoreFocusRef.current = false prevQueryRef.current = '' - prevScopeRef.current = 'worktrees' - setScope('worktrees') setQuery('') setDebouncedQuery('') setSelectedItemId('') @@ -328,15 +356,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } const queryChanged = debouncedQuery !== prevQueryRef.current - const scopeChanged = scope !== prevScopeRef.current prevQueryRef.current = debouncedQuery - prevScopeRef.current = scope const firstSelectableId = showCreateAction ? '__create_worktree__' : null - if (queryChanged || scopeChanged) { - if (visibleItems.length > 0) { - setSelectedItemId(visibleItems[0].id) + if (queryChanged) { + if (selectableItems.length > 0) { + setSelectedItemId(selectableItems[0].id) } else { setSelectedItemId(firstSelectableId ?? '') } @@ -344,7 +370,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } - if (visibleItems.length === 0) { + if (selectableItems.length === 0) { setSelectedItemId(firstSelectableId ?? '') return } @@ -354,12 +380,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( - !visibleItems.some((item) => item.id === selectedItemId) && + !selectableItems.some((item) => item.id === selectedItemId) && selectedItemId !== firstSelectableId ) { - setSelectedItemId(firstSelectableId ?? visibleItems[0].id) + setSelectedItemId(firstSelectableId ?? selectableItems[0].id) } - }, [debouncedQuery, scope, selectedItemId, showCreateAction, visible, visibleItems]) + }, [debouncedQuery, selectedItemId, showCreateAction, visible, selectableItems]) const focusFallbackSurface = useCallback(() => { requestAnimationFrame(() => { @@ -613,47 +639,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Radix steals focus. This callback exists only to satisfy the prop API. }, []) - const handleInputKeyDown = useCallback((event: React.KeyboardEvent) => { - if (event.key !== 'Tab') { - return - } - // Why: the scope chips are part of the palette's search model, not the - // browser's focus ring. Cycling them with Tab keeps the input focused and - // avoids turning scope changes into a pointer-only affordance. - event.preventDefault() - setScope((current) => nextScope(current, event.shiftKey ? -1 : 1)) - }, []) - - const title = scope === 'browser-tabs' ? 'Open Browser Tab' : 'Open Worktree' - const description = - scope === 'browser-tabs' - ? 'Search open browser pages across all worktrees' - : 'Search across all worktrees by name, branch, comment, PR, or issue' - const placeholder = - scope === 'browser-tabs' ? 'Search open browser tabs...' : 'Jump to worktree...' - - const resultCount = visibleItems.length + const resultCount = selectableItems.length const emptyState = (() => { - if (scope === 'browser-tabs') { - return hasAnyBrowserPages && hasQuery - ? { - title: 'No browser tabs match your search', - subtitle: 'Try a page title, URL, worktree name, or repo name.' - } - : { - title: 'No open browser tabs', - subtitle: 'Open a page in Orca and it will show up here.' - } + if ((hasAnyWorktrees || hasAnyBrowserPages) && hasQuery) { + return { + title: 'No results match your search', + subtitle: 'Try a name, branch, repo, comment, PR, page title, or URL.' + } + } + return { + title: 'No active worktrees or browser tabs', + subtitle: 'Create a worktree or open a page in Orca to get started.' } - return hasAnyWorktrees && hasQuery - ? { - title: 'No worktrees match your search', - subtitle: 'Try a name, branch, repo, comment, PR, or issue.' - } - : { - title: 'No active worktrees', - subtitle: 'Create one to get started, then jump back here any time.' - } })() return ( @@ -663,8 +660,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { shouldFilter={false} onOpenAutoFocus={handleOpenAutoFocus} onCloseAutoFocus={handleCloseAutoFocus} - title={title} - description={description} + title="Jump to..." + description="Search worktrees and browser tabs" overlayClassName="bg-black/55 backdrop-blur-[2px]" contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl" commandProps={{ @@ -675,69 +672,39 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }} > -
- {SCOPE_ORDER.map((candidate) => { - const active = candidate === scope - const label = candidate === 'worktrees' ? 'Worktrees' : 'Browser Tabs' - return ( - - ) - })} -
{isLoading ? ( - ) : visibleItems.length === 0 && !showCreateAction ? ( + ) : selectableItems.length === 0 && !showCreateAction ? ( ) : ( <> - {showCreateAction && ( - -
-
-
-
- {`Create worktree "${createWorktreeName}"`} + {listEntries.map((entry) => { + if (entry.type === 'section-header') { + return ( +
+ {entry.label}
-
- - )} - {visibleItems.map((item) => { - if (item.type === 'worktree') { - const worktree = item.worktree + ) + } + + if (entry.type === 'worktree') { + const worktree = entry.worktree const repo = repoMap.get(worktree.repoId) const repoName = repo?.displayName ?? '' const branch = branchName(worktree.branch) @@ -750,9 +717,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return ( handleSelectItem(item)} + key={entry.id} + value={entry.id} + onSelect={() => handleSelectItem(entry)} data-current={isCurrentWorktree ? 'true' : undefined} className={cn( 'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]', @@ -768,10 +735,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
- {item.match.displayNameRange ? ( + {entry.match.displayNameRange ? ( ) : ( worktree.displayName @@ -789,25 +756,25 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} · - {item.match.branchRange ? ( + {entry.match.branchRange ? ( ) : ( branch )}
- {item.match.supportingText && ( + {entry.match.supportingText && (
- {item.match.supportingText.label} + {entry.match.supportingText.label}
@@ -826,10 +793,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } /> - {item.match.repoRange ? ( + {entry.match.repoRange ? ( ) : ( repoName @@ -844,16 +811,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) } - const result = item.result + const result = entry.result const browserWorktree = worktreeMap.get(result.worktreeId) const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName return ( handleSelectItem(item)} + key={entry.id} + value={entry.id} + onSelect={() => handleSelectItem(entry)} className={cn( 'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]', 'data-[selected=true]:border-border data-[selected=true]:bg-neutral-100 data-[selected=true]:text-foreground dark:data-[selected=true]:bg-neutral-800' @@ -921,6 +888,25 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) })} + {showCreateAction && ( + // Why: render the create action last so cmdk does not briefly + // auto-select it before our effect promotes the first real match + // when the query only matches browser pages. + +
+
+
+
+ {`Create worktree "${createWorktreeName}"`} +
+
+
+ )} )} @@ -928,8 +914,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
Enter Open - Tab - Switch Esc Close ↑↓ @@ -938,8 +922,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
{debouncedQuery.trim() - ? `${resultCount} results found in ${scope === 'worktrees' ? 'worktrees' : 'browser tabs'}${showCreateAction ? ', create new worktree action available' : ''}` - : `${resultCount} ${scope === 'worktrees' ? 'worktrees' : 'browser tabs'} available${showCreateAction ? ', create new worktree action available' : ''}`} + ? `${resultCount} results found${showCreateAction ? ', create new worktree action available' : ''}` + : `${resultCount} items available${showCreateAction ? ', create new worktree action available' : ''}`}
)