Files
orca/src/renderer/src/components/WorktreeJumpPalette.tsx
T
Jinwoo-HandOrca 1384d9f6bf Live runtime status in sidebar; focused-host creation default; SSH auth-needed label
- New runtime-status slice holds per-environment status (boot hydration,
  settings-pane probes, host-header checks all write through it); stale
  environments dropped on list reload
- buildSidebarHostOptions forwards live status to the host registry at
  all call sites, so compat verdicts and blocked health render in the
  scope strip, options menu, host headers, and browser pane
- New-workspace composer defaults to a repo on the focused host scope
  (explicit draft/initial/active choices still win)
- SSH hosts with expired auth say "Authentication needed" in the host
  header instead of icon-only

Co-authored-by: Orca <help@stably.ai>
2026-06-09 19:39:07 -07:00

1844 lines
72 KiB
TypeScript

/* oxlint-disable max-lines */
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Globe, Plus, Server, ServerOff, Smartphone } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandItem
} from '@/components/ui/command'
import { branchName } from '@/lib/git-utils'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { sortWorktreesSmart } from '@/components/sidebar/smart-sort'
import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees'
import StatusIndicator from '@/components/sidebar/StatusIndicator'
import { cn } from '@/lib/utils'
import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import {
getWorktreePaletteSearchScope,
searchWorktrees,
type MatchRange,
type PaletteSearchResult
} from '@/lib/worktree-palette-search'
import {
CREATE_WORKTREE_ITEM_ID,
createWorktreePaletteRequestGuard,
getNextWorktreePaletteSelection,
getWorktreePaletteSelectionItemIds,
getWorktreePaletteCreateActionState
} from '@/lib/worktree-palette-create-action'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import {
isBlankBrowserUrl,
searchBrowserPages,
type BrowserPaletteSearchResult,
type SearchableBrowserPage
} from '@/lib/browser-palette-search'
import {
buildSearchableSimulatorTabs,
searchSimulatorTabs,
type SearchableSimulatorTab,
type SimulatorPaletteSearchResult
} from '@/lib/simulator-palette-search'
import {
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
queueBrowserFocusRequest
} from '@/components/browser-pane/browser-focus'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options'
import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
import {
buildCmdJActionResults,
buildCmdJSettingsResults,
rankCmdJMiddleResults,
type CmdJActionResult,
type CmdJSettingsResult
} from '@/components/cmd-j/palette-results'
import {
buildCmdJQuickActionContext,
captureCmdJActiveGroupSnapshot,
getUnavailableQuickActionMessage,
type CmdJActiveGroupSnapshot
} from '@/components/cmd-j/quick-action-context'
import {
getCmdJQuickActions,
CREATE_WORKSPACE_QUICK_ACTION_ID
} from '@/components/cmd-j/quick-actions'
import {
getComposerEligibleRepos,
resolveComposerGitRepoId
} from '@/lib/new-workspace-composer-repo'
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { translate } from '@/i18n/i18n'
type WorktreePaletteItem = {
id: string
type: 'worktree'
match: PaletteSearchResult
worktree: Worktree
}
type BrowserPaletteItem = {
id: string
type: 'browser-page'
result: BrowserPaletteSearchResult
}
type SimulatorPaletteItem = {
id: string
type: 'simulator-tab'
result: SimulatorPaletteSearchResult
}
type SettingsPaletteItem = {
id: string
type: 'settings'
result: CmdJSettingsResult
}
type QuickActionPaletteItem = {
id: string
type: 'quick-action'
result: CmdJActionResult
}
type SectionHeader = {
id: string
type: 'section-header'
label: string
}
type HintRow = {
id: string
type: 'hint'
label: string
}
type CreateWorktreePaletteItem = {
id: typeof CREATE_WORKTREE_ITEM_ID
type: 'create-worktree'
}
// Why: Cmd+J is a fast intent surface, not a dump of every setup button.
// Keep future quick actions curated; route one-time setup flows through Settings.
type PaletteItem =
| WorktreePaletteItem
| SettingsPaletteItem
| QuickActionPaletteItem
| BrowserPaletteItem
| SimulatorPaletteItem
type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader | HintRow
const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_QUICK_ACTION_ID}`
function getComposerPrefetchRepoId(
state: ReturnType<typeof useAppStore.getState>,
initialRepoId?: string
): string | null {
return resolveComposerGitRepoId({
eligibleRepos: getComposerEligibleRepos(state.repos),
initialRepoId,
activeRepoId: state.activeRepoId,
focusedHostScope: state.workspaceHostScope
})
}
function appendPaletteListEntries(
target: PaletteListEntry[],
source: readonly PaletteItem[]
): void {
// Why: query mode can expose generated-size workspace/tab result lists.
// Avoid the function argument limit from `push(...source)`.
for (const entry of source) {
target.push(entry)
}
}
type BrowserSelection = {
worktree: Worktree
workspace: BrowserWorkspace
page: BrowserPage
}
function HighlightedText({
text,
matchRange
}: {
text: string
matchRange: MatchRange | null
}): React.JSX.Element {
if (!matchRange) {
return <>{text}</>
}
const before = text.slice(0, matchRange.start)
const match = text.slice(matchRange.start, matchRange.end)
const after = text.slice(matchRange.end)
return (
<>
{before}
<span className="font-semibold text-foreground">{match}</span>
{after}
</>
)
}
function PaletteState({ title, subtitle }: { title: string; subtitle: string }): React.JSX.Element {
return (
<div className="px-5 py-8 text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
</div>
)
}
function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<span className="rounded-full border border-border/60 bg-muted/35 px-2 py-0.5 text-[10px] font-medium text-foreground/85">
{children}
</span>
)
}
function PaletteHostBadgeChip({
badge
}: {
badge: PaletteHostBadge | null
}): React.JSX.Element | null {
if (!badge) {
return null
}
// Host labels come from the registry and are intentionally not translated.
return (
<span
aria-label={translate(
'auto.components.WorktreeJumpPalette.paletteHostBadge',
'Host: {{value0}}',
{ value0: badge.label }
)}
className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88"
>
{badge.label}
</span>
)
}
function findBrowserSelection(
pageId: string,
workspaceId: string,
worktreeId: string
): BrowserSelection | null {
const state = useAppStore.getState()
const page = (state.browserPagesByWorkspace[workspaceId] ?? []).find((p) => p.id === pageId)
if (!page) {
return null
}
const workspace = (state.browserTabsByWorktree[worktreeId] ?? []).find(
(w) => w.id === workspaceId
)
if (!workspace) {
return null
}
const worktree = findWorktreeById(state.worktreesByRepo, worktreeId)
if (!worktree) {
return null
}
return { page, workspace, worktree }
}
function getSettingsTargetFromSectionId(sectionId: string): {
pane: SettingsNavTarget
repoId: string | null
sectionId?: string
} {
if (sectionId.startsWith('repo-')) {
return { pane: 'repo', repoId: sectionId.slice('repo-'.length) }
}
return { pane: sectionId as SettingsNavTarget, repoId: null }
}
export default function WorktreeJumpPalette(): React.JSX.Element | null {
const { i18n } = useTranslation()
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
// working agent in a non-focused pane still surface as 'working' in the
// jump palette. Without this, clicking between panes would desync the
// palette's spinner from the sidebar's spinner.
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
// Why: ptyIdsByTabId is the live-pty source of truth — without it,
// getWorktreeStatus would treat slept tabs as live (their preserved
// tab.ptyId is a wake-hint sessionId, not a liveness signal) and the jump
// palette dot would lie green even though the sidebar dot is correctly grey.
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeTabType = useAppStore((s) => s.activeTabType)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const settings = useAppStore((s) => s.settings)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
const openNewBrowserTabInActiveWorkspace = useAppStore(
(s) => s.openNewBrowserTabInActiveWorkspace
)
const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace)
const openNewTerminalTabInActiveWorkspace = useAppStore(
(s) => s.openNewTerminalTabInActiveWorkspace
)
const settingsSections = useSettingsNavigationMetadata()
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [selectedItemId, setSelectedItemId] = useState('')
const previousWorktreeIdRef = useRef<string | null>(null)
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'simulator'>(
'terminal'
)
const previousBrowserPageIdRef = useRef<string | null>(null)
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
const activeGroupSnapshotRef = useRef<CmdJActiveGroupSnapshot | null>(null)
const wasVisibleRef = useRef(false)
const skipRestoreFocusRef = useRef(false)
const listRef = useRef<HTMLDivElement>(null)
const fallbackFocusOuterFrameRef = useRef<number | null>(null)
const fallbackFocusInnerFrameRef = useRef<number | null>(null)
const createLookupGuard = useMemo(() => createWorktreePaletteRequestGuard(), [])
const preserveCreateLookupOnCloseRef = useRef(false)
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
// Why: host badges only appear when more than one execution host exists; reuse
// the same registry the sidebar host-scope strip builds so labels stay in sync.
const hostOptions = useMemo(
() => buildSidebarHostOptions({ repos, sshTargetLabels, sshConnectionStates, settings }),
[repos, sshTargetLabels, sshConnectionStates, settings]
)
const canCreateWorktree = repos.length > 0
const hasQuery = deferredQuery.trim().length > 0
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
// Why: the empty-query palette mirrors sidebar filters so opening Search
// starts from the same quiet list. Typed search switches to the global
// non-archived scope below.
const emptyQueryVisibleWorktrees = useMemo(
() =>
allWorktrees.filter((worktree) => {
if (worktree.isArchived) {
return false
}
if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) {
return false
}
if (
!showSleepingWorkspaces &&
isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
) {
return false
}
return true
}),
[
allWorktrees,
browserTabsByWorktree,
hideDefaultBranchWorkspace,
ptyIdsByTabId,
showSleepingWorkspaces,
tabsByWorktree
]
)
// Why: empty-query rows use focus-recency (lastVisitedAtByWorktreeId) with
// lastActivityAt fallback so SSH / quiet worktrees don't get pushed below
// the fold by noisy local worktrees. Current worktree is excluded from the
// empty-query rows per product model (Cmd+J is a switch surface, not a
// "show me everything" surface), but kept in visibleWorktreesForState so
// empty-state/loading logic remains unaffected.
// See docs/cmd-j-empty-query-ordering.md.
const { visibleWorktreesForState, switchableWorktreesForRows } = useMemo(
() =>
orderEmptyQueryWorktrees({
visibleWorktrees: emptyQueryVisibleWorktrees,
activeWorktreeId,
lastVisitedAtByWorktreeId
}),
[emptyQueryVisibleWorktrees, activeWorktreeId, lastVisitedAtByWorktreeId]
)
const searchScopeWorktrees = useMemo(
() =>
getWorktreePaletteSearchScope({
hasQuery,
allWorktrees,
emptyQueryWorktrees: switchableWorktreesForRows
}),
[allWorktrees, hasQuery, switchableWorktreesForRows]
)
// Why: typed queries still route through sortWorktreesSmart — switcher
// ranking only diverges from smart-sort on the empty-query branch.
const sortedWorktrees = useMemo(
() =>
hasQuery
? sortWorktreesSmart(
searchScopeWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
)
: searchScopeWorktrees,
[
hasQuery,
searchScopeWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
]
)
const browserSortedWorktrees = useMemo(() => {
// Why: browser-tab search is explicitly cross-worktree, so it must keep
// indexing live browser pages even when their owning worktree is archived
// or hidden by the default-branch-workspace setting. A user who opened a
// tab on the default-branch worktree before toggling hide-on should still
// be able to Cmd+J back to it — the setting hides the *workspace row*,
// not the browser tabs that live inside it.
return sortWorktreesSmart(
allWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
)
}, [
allWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
])
// Why: browser rows need worktree lookups for repo badge colors, and browser
// search intentionally includes archived worktrees. This map must cover all
// worktrees, not just the non-archived sortedWorktrees used for the Worktrees scope.
const worktreeMap = useMemo(() => {
const map = new Map<string, Worktree>()
for (const worktree of browserSortedWorktrees) {
map.set(worktree.id, worktree)
}
return map
}, [browserSortedWorktrees])
const worktreeOrder = useMemo(
() => new Map(browserSortedWorktrees.map((worktree, index) => [worktree.id, index])),
[browserSortedWorktrees]
)
const worktreeMatches = useMemo(
() =>
searchWorktrees(
sortedWorktrees,
deferredQuery.trim(),
repoMap,
prCache,
issueCache,
getWorkspacePortsByWorktreeId(workspacePortScan)
),
[sortedWorktrees, deferredQuery, repoMap, prCache, issueCache, workspacePortScan]
)
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
const entries: SearchableBrowserPage[] = []
for (const worktree of browserSortedWorktrees) {
const repoName = repoMap.get(worktree.repoId)?.displayName ?? ''
const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER
const workspaces = browserTabsByWorktree[worktree.id] ?? []
for (const workspace of workspaces) {
const pages = browserPagesByWorkspace[workspace.id] ?? []
for (const page of pages) {
entries.push({
page,
workspace,
worktree,
repoName,
worktreeSortIndex,
isCurrentPage:
workspace.id === activeBrowserTabId && workspace.activePageId === page.id,
isCurrentWorktree: activeWorktreeId === worktree.id
})
}
}
}
return entries
}, [
activeBrowserTabId,
activeWorktreeId,
browserPagesByWorkspace,
browserTabsByWorktree,
browserSortedWorktrees,
repoMap,
worktreeOrder
])
const browserMatches = useMemo(
() => searchBrowserPages(browserPageEntries, deferredQuery.trim()),
[browserPageEntries, deferredQuery]
)
const simulatorTabEntries = useMemo<SearchableSimulatorTab[]>(() => {
return buildSearchableSimulatorTabs({
worktrees: browserSortedWorktrees,
repoMap,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeTabType
})
}, [
activeGroupIdByWorktree,
activeTabType,
activeWorktreeId,
browserSortedWorktrees,
groupsByWorktree,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
])
const simulatorMatches = useMemo(
() => searchSimulatorTabs(simulatorTabEntries, deferredQuery.trim()),
[simulatorTabEntries, deferredQuery]
)
const worktreeItems = useMemo<WorktreePaletteItem[]>(
() =>
worktreeMatches
.map((match) => {
const worktree = worktreeMap.get(match.worktreeId)
if (!worktree) {
return null
}
return {
id: `worktree:${worktree.id}`,
type: 'worktree' as const,
match,
worktree
}
})
.filter((item): item is WorktreePaletteItem => item !== null),
[worktreeMap, worktreeMatches]
)
const browserItems = useMemo<BrowserPaletteItem[]>(
() =>
browserMatches.map((result) => ({
id: `browser-page:${result.pageId}`,
type: 'browser-page' as const,
result
})),
[browserMatches]
)
const simulatorItems = useMemo<SimulatorPaletteItem[]>(
() =>
simulatorMatches.map((result) => ({
id: `simulator-tab:${result.tabId}`,
type: 'simulator-tab' as const,
result
})),
[simulatorMatches]
)
const openTabItems = useMemo<(BrowserPaletteItem | SimulatorPaletteItem)[]>(
() =>
[...browserItems, ...simulatorItems].sort((a, b) => {
if (a.result.score !== b.result.score) {
return a.result.score - b.result.score
}
return a.id.localeCompare(b.id)
}),
[browserItems, simulatorItems]
)
const settingsResults = useMemo(
() => buildCmdJSettingsResults(settingsSections),
[settingsSections]
)
const actionResults = useMemo(
() => buildCmdJActionResults(getCmdJQuickActions()),
[i18n.language]
)
const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => {
const state = useAppStore.getState()
const repoIdForComposer = getComposerPrefetchRepoId(state, initialRepoId)
if (!repoIdForComposer) {
return
}
void state.prefetchWorktreeCreateBase(repoIdForComposer)
}, [])
const openCreateWorkspaceAction = useCallback(() => {
prefetchCreateWorkspaceBaseForComposer()
queueMicrotask(() =>
openModal('new-workspace-composer', { telemetrySource: 'command_palette' })
)
}, [openModal, prefetchCreateWorkspaceBaseForComposer])
const deleteActiveWorkspaceAction = useCallback(() => {
const { activeView, activeWorktreeId } = useAppStore.getState()
if (activeView !== 'terminal' || !activeWorktreeId) {
return
}
// Why: the delete confirmation is also a modal; let the palette close
// before mounting it so Radix focus teardown cannot fight the new dialog.
queueMicrotask(() => runWorktreeDelete(activeWorktreeId))
}, [])
const openAddQuickCommandAction = useCallback(() => {
openSettingsTarget({ pane: 'quick-commands', repoId: null, intent: 'add-quick-command' })
openSettingsPage()
}, [openSettingsPage, openSettingsTarget])
const buildQuickActionContext = useCallback(
() =>
buildCmdJQuickActionContext({
state: useAppStore.getState(),
activeGroupSnapshot: activeGroupSnapshotRef.current,
openNewBrowserTab: openNewBrowserTabInActiveWorkspace,
openNewMarkdownFile: openNewMarkdownInActiveWorkspace,
openNewTerminalTab: openNewTerminalTabInActiveWorkspace,
openCreateWorkspace: openCreateWorkspaceAction,
deleteActiveWorkspace: deleteActiveWorkspaceAction,
openAddQuickCommand: openAddQuickCommandAction
}),
[
deleteActiveWorkspaceAction,
openAddQuickCommandAction,
openCreateWorkspaceAction,
openNewBrowserTabInActiveWorkspace,
openNewMarkdownInActiveWorkspace,
openNewTerminalTabInActiveWorkspace
]
)
const quickActionContext = buildQuickActionContext()
const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>(
() =>
rankCmdJMiddleResults({
query: deferredQuery,
settingsResults,
actionResults: actionResults.filter(
(action) => action.isAvailable(quickActionContext).available
)
}).map((result) =>
result.kind === 'settings'
? { id: result.id, type: 'settings' as const, result }
: { id: `quick-action:${result.id}`, type: 'quick-action' as const, result }
),
[actionResults, deferredQuery, quickActionContext, settingsResults]
)
// Why: on empty query we cap the worktree section (not open tabs) so the
// OPEN TABS header + ≥1 tab row stays visible above the fold — users
// with 30+ worktrees would otherwise never see open tabs. The cap is
// paired with a "Type to see all N worktrees" hint row so the full list is
// one keystroke away. Typing lifts both caps. Cap size is tied to the
// palette's max-h-[min(460px,62vh)] viewport math: ~60px/row, ~32px/header,
// leaves room for OPEN TABS header + one tab row at default window size.
// Revisit if row heights or max-h change.
const EMPTY_QUERY_WORKTREE_CAP = 5
const EMPTY_QUERY_OPEN_TAB_CAP = 5
const paletteSections = useMemo(() => {
// Why: the worktree cap only earns its keep when there are open tabs to
// protect above-the-fold. With zero open tabs, capping would force
// the user to type for no reason — uncap so the recent list fills the
// viewport naturally.
const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity
const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap)
const visibleMiddleItems = hasQuery ? middleItems : []
const visibleOpenTabItems = hasQuery
? openTabItems
: openTabItems.slice(0, EMPTY_QUERY_OPEN_TAB_CAP)
const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap
return {
visibleWorktreeItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint
}
}, [worktreeItems, middleItems, openTabItems, hasQuery])
const selectableItems = useMemo<PaletteItem[]>(
() => [
...paletteSections.visibleWorktreeItems,
...paletteSections.visibleMiddleItems,
...paletteSections.visibleOpenTabItems
],
[paletteSections]
)
const { createWorktreeName, showCreateAction } = useMemo(
() =>
getWorktreePaletteCreateActionState({
canCreateWorktree,
query: deferredQuery
}),
[canCreateWorktree, deferredQuery]
)
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
const { visibleWorktreeItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } =
paletteSections
const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0)
const populatedSectionCount = [
visibleWorkspaceItemCount,
visibleMiddleItems.length,
visibleOpenTabItems.length
].filter((count) => count > 0).length
// Header rule: on empty query each section is categorically distinct
// (worktrees vs. open tabs), so a lone header is a useful signpost. On query,
// suppress headers unless both sections are populated — otherwise a lone
// header above one list is noise.
const showWorktreeHeader = hasQuery
? visibleWorkspaceItemCount > 0 && populatedSectionCount > 1
: visibleWorktreeItems.length > 0
const showOpenTabsHeader = hasQuery
? visibleOpenTabItems.length > 0 && populatedSectionCount > 1
: visibleOpenTabItems.length > 0
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
if (visibleWorkspaceItemCount > 0) {
if (showWorktreeHeader) {
entries.push({
id: '__header_worktrees__',
type: 'section-header',
label: hasQuery
? translate('auto.components.WorktreeJumpPalette.worktreesHeader', 'Worktrees')
: translate(
'auto.components.WorktreeJumpPalette.recentWorktreesHeader',
'Recent Worktrees'
)
})
}
appendPaletteListEntries(entries, visibleWorktreeItems)
if (showCreateAction) {
// Why: the typed create affordance is workspace-scoped, so keep it
// directly under workspace matches instead of after actions/tabs.
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
if (showWorktreeHint) {
entries.push({
id: '__hint_worktree_cap__',
type: 'hint',
label: translate(
'auto.components.WorktreeJumpPalette.dabd819ca1',
'Type to see all {{value0}} worktrees',
{ value0: worktreeItems.length }
)
})
}
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
entries.push({
id: '__header_actions_settings__',
type: 'section-header',
label: translate('auto.components.WorktreeJumpPalette.088d66d980', 'Actions & Settings')
})
}
appendPaletteListEntries(entries, visibleMiddleItems)
}
if (visibleOpenTabItems.length > 0) {
if (showOpenTabsHeader) {
entries.push({
id: '__header_open_tabs__',
type: 'section-header',
label: translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs')
})
}
appendPaletteListEntries(entries, visibleOpenTabItems)
}
return entries
}, [hasQuery, paletteSections, showCreateAction, worktreeItems.length, i18n.language])
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.
// See docs/cmd-j-empty-query-ordering.md.
const hasAnyWorktrees = visibleWorktreesForState.length > 0
const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees
const hasAnyOpenTabs = browserPageEntries.length > 0 || simulatorTabEntries.length > 0
const hasAnyMiddleResults = middleItems.length > 0
useEffect(() => {
if (visible && !wasVisibleRef.current) {
createLookupGuard.invalidate()
activeGroupSnapshotRef.current = captureCmdJActiveGroupSnapshot(
useAppStore.getState(),
activeWorktreeId
)
previousWorktreeIdRef.current = activeWorktreeId
previousActiveTabTypeRef.current = activeTabType
previousBrowserPageIdRef.current =
activeWorktreeId && activeTabType === 'browser'
? ((browserTabsByWorktree[activeWorktreeId] ?? []).find(
(workspace) => workspace.id === activeBrowserTabId
)?.activePageId ?? null)
: null
// Why: capture which browser surface had focus *before* Radix Dialog
// steals it. By onOpenAutoFocus time, document.activeElement has already
// moved to the dialog content, so address-bar detection must happen here.
previousBrowserFocusTargetRef.current =
activeTabType === 'browser' &&
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[data-orca-browser-address-bar="true"]')
? 'address-bar'
: 'webview'
skipRestoreFocusRef.current = false
setQuery('')
setSelectedItemId('')
listRef.current?.scrollTo(0, 0)
}
if (!visible && wasVisibleRef.current) {
if (preserveCreateLookupOnCloseRef.current) {
// Why: create intentionally closes the palette before GH resolves;
// reopening still invalidates the pending lookup above.
preserveCreateLookupOnCloseRef.current = false
} else {
createLookupGuard.invalidate()
}
activeGroupSnapshotRef.current = null
}
wasVisibleRef.current = visible
}, [
activeBrowserTabId,
activeTabType,
activeWorktreeId,
browserTabsByWorktree,
createLookupGuard,
visible
])
const commandSelectedItemId = getNextWorktreePaletteSelection({
currentSelectedItemId: selectedItemId,
queryChanged: false,
selectableItemIds: selectionItemIds,
showCreateAction
})
useEffect(() => {
const isCreateWorkspaceHighlighted =
commandSelectedItemId === CREATE_WORKTREE_ITEM_ID ||
commandSelectedItemId === CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID
if (!visible || !isCreateWorkspaceHighlighted) {
return
}
// Why: Cmd+J opens the composer after selection; warming the same default
// repo here buys time while the user is still on the highlighted row.
prefetchCreateWorkspaceBaseForComposer()
}, [commandSelectedItemId, prefetchCreateWorkspaceBaseForComposer, visible])
const handleQueryChange = useCallback((nextQuery: string) => {
setQuery(nextQuery)
setSelectedItemId('')
listRef.current?.scrollTo(0, 0)
}, [])
const cancelFallbackFocusFrames = useCallback((): void => {
if (fallbackFocusOuterFrameRef.current !== null) {
cancelAnimationFrame(fallbackFocusOuterFrameRef.current)
fallbackFocusOuterFrameRef.current = null
}
if (fallbackFocusInnerFrameRef.current !== null) {
cancelAnimationFrame(fallbackFocusInnerFrameRef.current)
fallbackFocusInnerFrameRef.current = null
}
}, [])
useEffect(() => cancelFallbackFocusFrames, [cancelFallbackFocusFrames])
const focusFallbackSurface = useCallback(() => {
cancelFallbackFocusFrames()
fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => {
fallbackFocusOuterFrameRef.current = null
fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => {
fallbackFocusInnerFrameRef.current = null
const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null
if (xterm) {
xterm.focus()
return
}
const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null
if (monaco) {
monaco.focus()
}
})
})
}, [cancelFallbackFocusFrames])
const requestBrowserFocus = useCallback(
(detail: { pageId: string; target: 'webview' | 'address-bar' }) => {
queueBrowserFocusRequest(detail)
window.dispatchEvent(
new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, {
detail
})
)
},
[]
)
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
return
}
closeModal()
if (skipRestoreFocusRef.current) {
return
}
if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) {
// Why: dismissing Cmd+J from a browser surface should return focus to
// that page, not fall through to the generic terminal/editor fallback.
requestBrowserFocus({
pageId: previousBrowserPageIdRef.current,
target: previousBrowserFocusTargetRef.current
})
return
}
if (previousWorktreeIdRef.current) {
focusFallbackSurface()
}
},
[closeModal, focusFallbackSurface, requestBrowserFocus]
)
const handleSelectWorktree = useCallback(
(worktreeId: string) => {
const worktree = findWorktreeById(useAppStore.getState().worktreesByRepo, worktreeId)
if (!worktree) {
toast.error(
translate('auto.components.WorktreeJumpPalette.2c38630a01', 'Workspace no longer exists')
)
return
}
activateAndRevealWorktree(worktreeId)
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
focusFallbackSurface()
},
[closeModal, focusFallbackSurface]
)
const handleSelectBrowserPage = useCallback(
(result: BrowserPaletteSearchResult) => {
const { pageId, workspaceId, worktreeId } = result
const selection = findBrowserSelection(pageId, workspaceId, worktreeId)
if (!selection) {
toast.error(
translate(
'auto.components.WorktreeJumpPalette.d7d496a451',
'Browser page no longer exists'
)
)
return
}
// Why: capture the workspace and page info before activateAndRevealWorktree
// mutates store state. Store cascades during worktree activation can remap
// browser workspace state, making a second findBrowserSelection unreliable.
const { worktree, workspace, page } = selection
const activated = activateAndRevealWorktree(worktree.id)
if (!activated) {
toast.error(
translate('auto.components.WorktreeJumpPalette.2c38630a01', 'Workspace no longer exists')
)
return
}
const state = useAppStore.getState()
state.setActiveBrowserTab(workspace.id)
state.setActiveBrowserPage(workspace.id, pageId)
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
requestBrowserFocus({
pageId,
target: isBlankBrowserUrl(page.url) ? 'address-bar' : 'webview'
})
},
[closeModal, requestBrowserFocus]
)
const handleSelectSimulatorTab = useCallback(
(result: SimulatorPaletteSearchResult) => {
const state = useAppStore.getState()
const tab = (state.unifiedTabsByWorktree[result.worktreeId] ?? []).find(
(candidate) => candidate.id === result.tabId && candidate.contentType === 'simulator'
)
if (!tab) {
toast.error(
translate(
'auto.components.WorktreeJumpPalette.7726ce9970',
'Mobile emulator tab no longer exists'
)
)
return
}
const activated = activateAndRevealWorktree(result.worktreeId)
if (!activated) {
toast.error(
translate('auto.components.WorktreeJumpPalette.2c38630a01', 'Workspace no longer exists')
)
return
}
const nextState = useAppStore.getState()
nextState.focusGroup(result.worktreeId, tab.groupId)
nextState.activateTab(tab.id)
nextState.setActiveTab(tab.id)
nextState.setActiveTabType('simulator')
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
},
[closeModal]
)
const handleSelectSettings = useCallback(
(result: CmdJSettingsResult) => {
const target = getSettingsTargetFromSectionId(result.sectionId)
if (result.targetSectionId) {
target.sectionId = result.targetSectionId
}
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
openSettingsTarget(target)
openSettingsPage()
},
[closeModal, openSettingsPage, openSettingsTarget]
)
const handleSelectQuickAction = useCallback(
(action: CmdJActionResult) => {
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
const ctx = buildQuickActionContext()
void action.run(ctx).then((result) => {
if (result.status === 'unavailable') {
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
}
})
},
[buildQuickActionContext, closeModal]
)
const handleSelectItem = useCallback(
(item: PaletteItem) => {
if (item.type === 'worktree') {
handleSelectWorktree(item.worktree.id)
} else if (item.type === 'browser-page') {
handleSelectBrowserPage(item.result)
} else if (item.type === 'simulator-tab') {
handleSelectSimulatorTab(item.result)
} else if (item.type === 'settings') {
handleSelectSettings(item.result)
} else {
handleSelectQuickAction(item.result)
}
},
[
handleSelectBrowserPage,
handleSelectQuickAction,
handleSelectSettings,
handleSelectSimulatorTab,
handleSelectWorktree
]
)
const handleCreateWorktree = useCallback(() => {
skipRestoreFocusRef.current = true
const trimmed = createWorktreeName.trim()
const ghLink = parseGitHubIssueOrPRLink(trimmed)
const ghNumber = parseGitHubIssueOrPRNumber(trimmed)
const openComposer = (data: Record<string, unknown>): void => {
prefetchCreateWorkspaceBaseForComposer(
typeof data.initialRepoId === 'string' ? data.initialRepoId : undefined
)
closeModal()
// Why: defer opening so Radix fully unmounts the palette's dialog before
// the composer modal mounts, avoiding focus churn between the two.
queueMicrotask(() =>
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
)
}
// Case 1: user pasted a GH issue/PR URL.
if (ghLink) {
const { slug, number } = ghLink
const state = useAppStore.getState()
// Why: the existing-worktree check only needs the issue/PR number, which
// is repo-agnostic on the worktree meta side. We don't currently cache a
// repo-slug map, so slug-matching against a specific repo happens
// implicitly when we pick a repo for the `gh workItem` lookup below.
const matches = allWorktrees.filter(
(w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number)
)
const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0]
if (activeMatch) {
closeModal()
activateAndRevealWorktree(activeMatch.id)
return
}
// Resolve via gh.workItem: prefer the active repo, else the first eligible.
const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r))
const repoForLookup =
(state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) ||
eligibleRepos[0]
if (!repoForLookup) {
openComposer({ prefilledName: trimmed })
return
}
prefetchCreateWorkspaceBaseForComposer(repoForLookup.id)
// Why: awaiting inside the user gesture would leave the palette open
// indefinitely on slow networks. Close immediately and populate the
// composer once the lookup returns.
const lookupToken = createLookupGuard.start()
preserveCreateLookupOnCloseRef.current = true
closeModal()
void window.api.gh
.workItemByOwnerRepo({
repoPath: repoForLookup.path,
repoId: repoForLookup.id,
owner: slug.owner,
repo: slug.repo,
number,
type: ghLink.type
})
.then((item) => {
if (!createLookupGuard.isCurrent(lookupToken)) {
return
}
const data: Record<string, unknown> = { initialRepoId: repoForLookup.id }
if (item) {
const linkedWorkItem: LinkedWorkItemSummary = {
type: item.type,
number: item.number,
title: item.title,
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName =
getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ??
getLinkedWorkItemSuggestedName({ title: item.title })
} else {
// Fallback: we couldn't resolve the URL, just seed the name.
data.prefilledName = `${slug.owner}-${slug.repo}-${number}`
}
queueMicrotask(() =>
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
)
})
.catch(() => {
if (!createLookupGuard.isCurrent(lookupToken)) {
return
}
queueMicrotask(() =>
openModal('new-workspace-composer', {
initialRepoId: repoForLookup.id,
telemetrySource: 'command_palette'
})
)
})
return
}
// Case 2: user typed a raw issue number. Resolve against the active repo.
if (ghNumber !== null) {
const state = useAppStore.getState()
const matches = allWorktrees.filter(
(w) => !w.isArchived && (w.linkedIssue === ghNumber || w.linkedPR === ghNumber)
)
const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0]
if (activeMatch) {
closeModal()
activateAndRevealWorktree(activeMatch.id)
return
}
const repoForLookup =
(state.activeRepoId ? (repoMap.get(state.activeRepoId) ?? null) : null) ||
[...getRepoMapFromState(state).values()].find((repo) => isGitRepoKind(repo))
if (!repoForLookup || !isGitRepoKind(repoForLookup)) {
openComposer({ prefilledName: trimmed })
return
}
prefetchCreateWorkspaceBaseForComposer(repoForLookup.id)
const lookupToken = createLookupGuard.start()
preserveCreateLookupOnCloseRef.current = true
closeModal()
void window.api.gh
.workItem({ repoPath: repoForLookup.path, repoId: repoForLookup.id, number: ghNumber })
.then((item) => {
if (!createLookupGuard.isCurrent(lookupToken)) {
return
}
const data: Record<string, unknown> = { initialRepoId: repoForLookup.id }
if (item) {
const linkedWorkItem: LinkedWorkItemSummary = {
type: item.type,
number: item.number,
title: item.title,
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName =
getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ??
getLinkedWorkItemSuggestedName({ title: item.title })
} else {
data.prefilledName = trimmed
}
queueMicrotask(() =>
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
)
})
.catch(() => {
if (!createLookupGuard.isCurrent(lookupToken)) {
return
}
queueMicrotask(() =>
openModal('new-workspace-composer', {
initialRepoId: repoForLookup.id,
prefilledName: trimmed,
telemetrySource: 'command_palette'
})
)
})
return
}
// Case 3: plain name — open composer prefilled.
openComposer(trimmed ? { prefilledName: trimmed } : {})
}, [
allWorktrees,
closeModal,
createLookupGuard,
createWorktreeName,
openModal,
prefetchCreateWorkspaceBaseForComposer,
repoMap
])
const handleCloseAutoFocus = useCallback((e: Event) => {
e.preventDefault()
}, [])
const handleOpenAutoFocus = useCallback((_event: Event) => {
// No-op: address-bar detection is handled in the visible effect before
// Radix steals focus. This callback exists only to satisfy the prop API.
}, [])
const resultCount = selectableItems.length
const emptyState = (() => {
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyOpenTabs) && hasQuery) {
return {
title: translate(
'auto.components.WorktreeJumpPalette.dbd9d87eec',
'No results match your search'
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.c4afa68159',
'Try a worktree, setting, action, page title, emulator, URL, PR, or port.'
)
}
}
// Why: empty-query rows exclude the current worktree, so a single-worktree
// setup has hasAnyWorktrees=true but zero switchable rows. Without this
// branch the palette would claim "No active worktrees" while one is open
// — misleading. See docs/cmd-j-empty-query-ordering.md.
if (!hasQuery && hasAnyWorktrees && !hasAnyOpenTabs) {
return {
title: translate(
'auto.components.WorktreeJumpPalette.f60f8730be',
'No other worktrees to switch to'
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.b781ae05e3',
'Type to search worktrees, settings, tabs, and actions.'
)
}
}
return {
title: translate(
'auto.components.WorktreeJumpPalette.1628fd7dfa',
'No active worktrees, settings, actions, or open tabs'
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.f7fda8d562',
'Create a worktree or open a tab in Orca to get started.'
)
}
})()
return (
<CommandDialog
open={visible}
onOpenChange={handleOpenChange}
shouldFilter={false}
onOpenAutoFocus={handleOpenAutoFocus}
onCloseAutoFocus={handleCloseAutoFocus}
title={translate('auto.components.WorktreeJumpPalette.4ee378034d', 'Jump to...')}
description={translate(
'auto.components.WorktreeJumpPalette.4e4ff044d5',
'Search worktrees, settings, tabs, and actions'
)}
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={{
loop: true,
value: commandSelectedItemId,
onValueChange: setSelectedItemId,
className: 'bg-transparent'
}}
>
<CommandInput
placeholder={translate(
'auto.components.WorktreeJumpPalette.1ebe225fee',
'Search worktrees, settings, tabs, and actions...'
)}
value={query}
onValueChange={handleQueryChange}
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60"
className="h-12 text-[14px] placeholder:text-muted-foreground/75"
/>
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-2">
{isLoading && selectableItems.length === 0 && !showCreateAction ? (
<PaletteState
title={translate(
'auto.components.WorktreeJumpPalette.ff908adfe9',
'Loading jump targets'
)}
subtitle={translate(
'auto.components.WorktreeJumpPalette.684e8d7bc2',
'Gathering your recent worktrees and open tabs.'
)}
/>
) : selectableItems.length === 0 && !showCreateAction ? (
<CommandEmpty className="py-0">
<PaletteState title={emptyState.title} subtitle={emptyState.subtitle} />
</CommandEmpty>
) : (
<>
{listEntries.map((entry) => {
if (entry.type === 'section-header') {
return (
<div
key={entry.id}
className="mx-0.5 mt-3 mb-1 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"
>
{entry.label}
</div>
)
}
if (entry.type === 'hint') {
// Why: plain div (not CommandItem) so cmdk can't land selection
// on it and arrow keys skip over it naturally via selectableItems.
return (
<div
key={entry.id}
className="mx-0.5 mt-1 px-3 py-1.5 text-[12px] italic text-muted-foreground/70"
>
{entry.label}
</div>
)
}
if (entry.type === 'create-worktree') {
return (
<CommandItem
key={entry.id}
value={CREATE_WORKTREE_ITEM_ID}
onSelect={handleCreateWorktree}
className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.5 text-left outline-none transition-[background-color,border-color,box-shadow] data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
<Plus size={13} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{translate(
'auto.components.WorktreeJumpPalette.95be6587d3',
'Create worktree "{{value0}}"',
{ value0: createWorktreeName }
)}
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'worktree') {
const worktree = entry.worktree
const repo = repoMap.get(worktree.repoId)
const repoName = repo?.displayName ?? ''
const branch = branchName(worktree.branch)
const status = getWorktreeStatus(
tabsByWorktree[worktree.id] ?? [],
browserTabsByWorktree[worktree.id] ?? [],
ptyIdsByTabId,
runtimePaneTitlesByTabId
)
const statusLabel = getWorktreeStatusLabel(status)
const isCurrentWorktree = activeWorktreeId === worktree.id
const sshConnectionId = repo?.connectionId ?? null
const sshStatus = sshConnectionId
? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected')
: null
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
const hostBadge = getPaletteHostBadge(repo, hostOptions)
return (
<CommandItem
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]',
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5">
<StatusIndicator status={status} aria-hidden="true" />
<span className="sr-only">{statusLabel}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{sshConnectionId && (
<span
aria-label={
isSshDisconnected
? translate(
'auto.components.WorktreeJumpPalette.63c2be1914',
'SSH disconnected'
)
: translate(
'auto.components.WorktreeJumpPalette.34c8fbb46e',
'SSH remote'
)
}
className="shrink-0 inline-flex items-center"
>
{isSshDisconnected ? (
<ServerOff className="size-3.5 text-red-400" aria-hidden="true" />
) : (
<Server
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</span>
)}
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
text={worktree.displayName}
matchRange={entry.match.displayNameRange}
/>
) : (
worktree.displayName
)}
</span>
{isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.556e7232ca',
'Current'
)}
</span>
)}
{worktree.isMainWorktree && (
<span className="shrink-0 self-center rounded border border-muted-foreground/30 bg-muted-foreground/5 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground">
{translate(
'auto.components.WorktreeJumpPalette.739bda980c',
'primary'
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="truncate text-[12px] font-medium text-muted-foreground/92">
{entry.match.branchRange ? (
<HighlightedText
text={branch}
matchRange={entry.match.branchRange}
/>
) : (
branch
)}
</span>
</div>
{entry.match.supportingText && (
<div className="mt-1.5 flex min-w-0 items-center gap-2 text-[12px] leading-5 text-muted-foreground/88">
<span className="inline-flex h-[18px] shrink-0 items-center rounded border border-border bg-foreground/[0.04] px-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{getPaletteSupportingTextLabel(
entry.match.supportingText.labelKind
)}
</span>
<span className="truncate">
<HighlightedText
text={entry.match.supportingText.text}
matchRange={entry.match.supportingText.matchRange}
/>
</span>
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
{repoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={repo?.badgeColor} />
<span className="truncate">
{entry.match.repoRange ? (
<HighlightedText
text={repoName}
matchRange={entry.match.repoRange}
/>
) : (
repoName
)}
</span>
</span>
)}
<PaletteHostBadgeChip badge={hostBadge} />
</div>
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'settings' || entry.type === 'quick-action') {
const result = entry.result
const Icon = result.icon
const kindLabel =
entry.type === 'settings'
? translate('auto.components.WorktreeJumpPalette.settingsBadge', 'Settings')
: translate('auto.components.WorktreeJumpPalette.actionBadge', 'Action')
return (
<CommandItem
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-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<Icon className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{result.title}
</span>
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{kindLabel}
</span>
</div>
<div className="mt-1 truncate text-[12px] leading-5 text-muted-foreground/88">
{result.description}
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'simulator-tab') {
const result = entry.result
const simulatorWorktree = worktreeMap.get(result.worktreeId)
const simulatorRepo = simulatorWorktree
? repoMap.get(simulatorWorktree.repoId)
: undefined
const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName
const simulatorHostBadge = getPaletteHostBadge(simulatorRepo, hostOptions)
return (
<CommandItem
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-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<Smartphone className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
{simulatorRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={simulatorRepo?.badgeColor} />
<span className="truncate">
<HighlightedText
text={simulatorRepoName}
matchRange={result.repoRange}
/>
</span>
</span>
)}
<PaletteHostBadgeChip badge={simulatorHostBadge} />
</div>
</div>
</div>
</CommandItem>
)
}
const result = entry.result
const browserWorktree = worktreeMap.get(result.worktreeId)
const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined
const browserRepoName = browserRepo?.displayName ?? result.repoName
const browserHostBadge = getPaletteHostBadge(browserRepo, hostOptions)
return (
<CommandItem
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-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<Globe className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentPage && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
{!result.isCurrentPage && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
{browserRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={browserRepo?.badgeColor} />
<span className="truncate">
<HighlightedText
text={browserRepoName}
matchRange={result.repoRange}
/>
</span>
</span>
)}
<PaletteHostBadgeChip badge={browserHostBadge} />
</div>
</div>
</div>
</CommandItem>
)
})}
</>
)}
</CommandList>
<div className="flex items-center justify-end border-t border-border/60 px-3.5 py-2.5 text-[11px] text-muted-foreground/82">
<div className="flex items-center gap-2">
<FooterKey>
{translate('auto.components.WorktreeJumpPalette.f65d992a11', 'Enter')}
</FooterKey>
<span>{translate('auto.components.WorktreeJumpPalette.45def60329', 'Open')}</span>
<FooterKey>
{translate('auto.components.WorktreeJumpPalette.66b5a67bee', 'Esc')}
</FooterKey>
<span>{translate('auto.components.WorktreeJumpPalette.75499e01d9', 'Close')}</span>
<FooterKey>↑↓</FooterKey>
<span>{translate('auto.components.WorktreeJumpPalette.ac037cfac2', 'Move')}</span>
</div>
</div>
<div aria-live="polite" className="sr-only">
{deferredQuery.trim()
? translate(
'auto.components.WorktreeJumpPalette.bb72c08e63',
'{{value0}} results found{{value1}}',
{
value0: resultCount,
value1: showCreateAction ? ', create worktree action available' : ''
}
)
: translate(
'auto.components.WorktreeJumpPalette.20af998bff',
'{{value0}} items available{{value1}}',
{
value0: resultCount,
value1: showCreateAction ? ', create worktree action available' : ''
}
)}
</div>
</CommandDialog>
)
}
function getPaletteSupportingTextLabel(
labelKind: NonNullable<PaletteSearchResult['supportingText']>['labelKind']
): string {
switch (labelKind) {
case 'comment':
return translate('worktreeJumpPalette.matchLabel.comment', 'Comment')
case 'issue':
return translate('worktreeJumpPalette.matchLabel.issue', 'Issue')
case 'port':
return translate('worktreeJumpPalette.matchLabel.port', 'Port')
case 'pr':
return translate('worktreeJumpPalette.matchLabel.pr', 'PR')
}
}