fix(renderer): restore worktree palette behavior after split

(cherry picked from commit 0b78c1cbbc)
This commit is contained in:
Neil
2026-08-31 23:37:11 -07:00
parent 9b2131f00a
commit 4b775aa9ae
22 changed files with 1233 additions and 344 deletions
@@ -11,6 +11,7 @@ import { useWorktreeJumpPaletteListEntries } from './use-worktree-jump-palette-l
import { useWorktreeJumpPaletteSelectionLifecycle } from './use-worktree-jump-palette-selection-lifecycle'
import { useWorktreeJumpPaletteSelectionActions } from './use-worktree-jump-palette-selection-actions'
import { useWorktreeJumpPaletteCreateAction } from './use-worktree-jump-palette-create-action'
import { useWorktreeJumpPaletteTaskUrl } from './use-worktree-jump-palette-task-url'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
export function useWorktreeJumpPaletteController({
@@ -23,7 +24,13 @@ export function useWorktreeJumpPaletteController({
createLookupGuard: WorktreePaletteRequestGuard
}) {
const storeState = useWorktreeJumpPaletteStoreState({ visible, lingering })
const localState = useWorktreeJumpPaletteLocalState({ createLookupGuard })
const localState = useWorktreeJumpPaletteLocalState({ createLookupGuard, visible })
const taskUrl = useWorktreeJumpPaletteTaskUrl({
visible,
createWorktreeName: localState.createWorktreeName,
taskSourceUrl: localState.taskSourceUrl,
createLookupGuard
})
const filter = useWorktreeJumpPaletteFilter({ ...storeState, ...localState })
const worktrees = useWorktreeJumpPaletteWorktrees({
...storeState,
@@ -63,13 +70,15 @@ export function useWorktreeJumpPaletteController({
...openTabs,
...recentTabs,
...projectTargets,
...quickActions
...quickActions,
...taskUrl
})
const listEntries = useWorktreeJumpPaletteListEntries({
...localState,
...worktrees,
...openTabs,
...sections
...sections,
...taskUrl
})
const selectionLifecycle = useWorktreeJumpPaletteSelectionLifecycle({
...storeState,
@@ -81,7 +90,8 @@ export function useWorktreeJumpPaletteController({
...projectTargets,
...quickActions,
...sections,
...listEntries
...listEntries,
...taskUrl
})
const selectionActions = useWorktreeJumpPaletteSelectionActions({
...storeState,
@@ -102,12 +112,14 @@ export function useWorktreeJumpPaletteController({
...quickActions,
...sections,
...selectionLifecycle,
...selectionActions
...selectionActions,
...taskUrl
})
return {
...storeState,
...localState,
...taskUrl,
...filter,
...worktrees,
...openTabs,
@@ -12,7 +12,13 @@ import { subscribeCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump'
import { getRepoMapFromState } from '@/store/selectors'
import { useAppStore } from '@/store'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context'
import {
buildTaskSourceContextFromRepo,
normalizeTaskSourceContext
} from '../../../shared/task-source-context'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import { isWorktreePaletteCreateActivationAllowed } from '@/lib/worktree-palette-create-action'
import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteQuickActions } from './use-worktree-jump-palette-quick-actions'
@@ -21,6 +27,7 @@ import type { WorktreeJumpPaletteSelectionActions } from './use-worktree-jump-pa
import type { WorktreeJumpPaletteSelectionLifecycle } from './use-worktree-jump-palette-selection-lifecycle'
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees'
import type { WorktreeJumpPaletteTaskUrl } from './use-worktree-jump-palette-task-url'
type WorktreeJumpPaletteCreateActionInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteLocalState &
@@ -29,6 +36,7 @@ type WorktreeJumpPaletteCreateActionInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteSections &
WorktreeJumpPaletteSelectionActions &
WorktreeJumpPaletteSelectionLifecycle &
WorktreeJumpPaletteTaskUrl &
Pick<WorktreeJumpPaletteWorktrees, 'hasQuery'>
export function useWorktreeJumpPaletteCreateAction({
@@ -50,30 +58,41 @@ export function useWorktreeJumpPaletteCreateAction({
createLookupGuard,
preserveCreateLookupOnCloseRef,
inputRef,
setDialogElement
setDialogElement,
taskSourceUrl,
linearIssueUrlIntent,
currentLinearIssuePreview,
currentGitHubWorkItemPreview,
linearLookupRef,
githubLookupRef,
taskUrlCreatePreview,
selectionMovedByUserRef
}: WorktreeJumpPaletteCreateActionInput) {
useLayoutEffect(() => {
digitShortcutItemsRef.current = paletteSections.visibleOpenTabItems
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the controller ref preserves its original stable identity.
}, [paletteSections])
}, [digitShortcutItemsRef, paletteSections])
useEffect(() => {
if (!visible || hasQuery || query.length > 0) {
return
}
if (!visible || hasQuery || query.length > 0) return
return subscribeCmdJRowIndexJump((index) => {
const item = digitShortcutItemsRef.current[index]
if (item) {
handleSelectItem(item)
}
if (item) handleSelectItem(item)
})
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the controller ref preserves its original stable identity.
}, [handleSelectItem, hasQuery, query.length, visible])
}, [digitShortcutItemsRef, handleSelectItem, hasQuery, query.length, visible])
const handleCreateWorktree = useCallback(() => {
skipRestoreFocusRef.current = true
const trimmed = createWorktreeName.trim()
if (query.trim() !== trimmed) return
if (
!isWorktreePaletteCreateActivationAllowed({
hasTaskUrlIntent: taskSourceUrl !== null,
hasCreateName: trimmed.length > 0,
selectionMovedByUser: selectionMovedByUserRef.current
})
) return
const ghLink = parseGitHubIssueOrPRLink(trimmed)
const ghNumber = parseGitHubIssueOrPRNumber(trimmed)
const openComposer = (data: Record<string, unknown>): void => {
skipRestoreFocusRef.current = true
prefetchCreateWorkspaceBaseForComposer(
typeof data.initialRepoId === 'string' ? data.initialRepoId : undefined
)
@@ -83,35 +102,108 @@ export function useWorktreeJumpPaletteCreateAction({
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
)
}
if (ghLink) {
const { number } = ghLink
const state = useAppStore.getState()
const matches = allWorktrees.filter(
(worktree) =>
!worktree.isArchived && (worktree.linkedIssue === number || worktree.linkedPR === number)
)
const activeMatch =
matches.find((worktree) => worktree.repoId === state.activeRepoId) ?? matches[0]
if (activeMatch) {
closeModal()
const activation = activateAndRevealWorktree(activeMatch.id)
if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) {
focusFallbackSurface()
if (linearIssueUrlIntent) {
const lookup = linearLookupRef.current
const resolve = async (): Promise<void> => {
const preview = currentLinearIssuePreview?.loading
? await lookup?.promise
: (currentLinearIssuePreview ?? (await lookup?.promise))
if (
!preview ||
lookup?.query !== trimmed ||
linearLookupRef.current !== lookup ||
query.trim() !== trimmed ||
useAppStore.getState().activeModal !== 'worktree-palette'
) {
return
}
recordFeatureInteraction('cmd-j-workspace-open')
return
const data = preview.issue
? (() => {
const sourceContext = preview.sourceContext
? normalizeTaskSourceContext({
...preview.sourceContext,
providerIdentity: {
provider: 'linear',
workspaceId: preview.issue.workspaceId ?? null,
workspaceName: preview.issue.workspaceName ?? null,
teamId: preview.issue.team.id,
teamKey: preview.issue.team.key
},
accountLabel: preview.issue.workspaceName ?? null
})
: null
return {
prefilledName: getLinearIssueWorkspaceName(preview.issue),
linkedWorkItem: buildLinearIssueLinkedWorkItem(preview.issue),
...(preview.initialRepoId ? { initialRepoId: preview.initialRepoId } : {}),
...(sourceContext ? { taskSourceContext: sourceContext } : {})
}
})()
: preview.initialRepoId
? { prefilledName: trimmed, initialRepoId: preview.initialRepoId }
: { prefilledName: trimmed }
openComposer(data)
}
const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo))
const repoForLookup =
(state.activeRepoId && eligibleRepos.find((repo) => repo.id === state.activeRepoId)) ||
eligibleRepos[0]
openComposer(
repoForLookup
? { prefilledName: trimmed, initialRepoId: repoForLookup.id }
: { prefilledName: trimmed }
)
void resolve()
return
}
if (ghLink) {
const lookup = githubLookupRef.current
const resolve = async (): Promise<void> => {
const preview = currentGitHubWorkItemPreview?.loading
? await lookup?.promise
: (currentGitHubWorkItemPreview ?? (await lookup?.promise))
if (
!preview ||
lookup?.query !== trimmed ||
githubLookupRef.current !== lookup ||
query.trim() !== trimmed ||
useAppStore.getState().activeModal !== 'worktree-palette'
) {
return
}
const item = preview.item
if (item) {
const linkedWorkItem: LinkedWorkItemSummary = {
provider: 'github',
type: item.type,
number: item.number,
title: item.title,
url: item.url,
...(item.repoId ? { repoId: item.repoId } : {})
}
openComposer({
prefilledName:
getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ??
getLinkedWorkItemSuggestedName({ title: item.title }),
linkedWorkItem,
initialGitHubWorkItem: item,
...(preview.initialRepoId ? { initialRepoId: preview.initialRepoId } : {}),
...(preview.sourceContext ? { taskSourceContext: preview.sourceContext } : {})
})
} else {
openComposer({
prefilledName: trimmed,
...(preview.initialRepoId ? { initialRepoId: preview.initialRepoId } : {})
})
}
}
void resolve()
return
}
if (taskUrlCreatePreview) {
const state = useAppStore.getState()
const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo))
const repo =
(state.activeRepoId && eligibleRepos.find((candidate) => candidate.id === state.activeRepoId)) ||
eligibleRepos[0]
openComposer(repo ? { prefilledName: trimmed, initialRepoId: repo.id } : { prefilledName: trimmed })
return
}
if (ghNumber !== null) {
const state = useAppStore.getState()
const matches = allWorktrees.filter(
@@ -122,97 +214,104 @@ export function useWorktreeJumpPaletteCreateAction({
const activeMatch =
matches.find((worktree) => worktree.repoId === state.activeRepoId) ?? matches[0]
if (activeMatch) {
skipRestoreFocusRef.current = true
closeModal()
const activation = activateAndRevealWorktree(activeMatch.id)
const activation = activateAndRevealWorktree(
activeMatch.id,
activeMatch.hostId ? { executionHostId: activeMatch.hostId } : {}
)
if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) {
focusFallbackSurface()
}
recordFeatureInteraction('cmd-j-workspace-open')
return
}
const repoForLookup =
const repo =
(state.activeRepoId ? (repoMap.get(state.activeRepoId) ?? null) : null) ||
[...getRepoMapFromState(state).values()].find((repo) => isGitRepoKind(repo))
if (!repoForLookup || !isGitRepoKind(repoForLookup)) {
[...getRepoMapFromState(state).values()].find((candidate) => isGitRepoKind(candidate))
if (!repo || !isGitRepoKind(repo)) {
openComposer({ prefilledName: trimmed })
return
}
prefetchCreateWorkspaceBaseForComposer(repoForLookup.id)
const sourceContext = buildTaskSourceContextFromRepo({
provider: 'github',
projectId: repoForLookup.id,
repo: repoForLookup
projectId: repo.id,
repo
})
const lookupToken = createLookupGuard.start()
const token = createLookupGuard.start()
preserveCreateLookupOnCloseRef.current = true
recordFeatureInteraction('cmd-j-create-workspace')
skipRestoreFocusRef.current = true
closeModal()
void lookupGitHubWorkItemForSource({
repoPath: repoForLookup.path,
repoId: repoForLookup.id,
repoPath: repo.path,
repoId: repo.id,
sourceContext,
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
}
if (!createLookupGuard.isCurrent(token)) return
const linkedWorkItem = item
? { type: item.type, number: item.number, title: item.title, url: item.url }
: null
queueMicrotask(() =>
openModal('new-workspace-composer', {
initialRepoId: repoForLookup.id,
prefilledName: trimmed,
initialRepoId: repo.id,
...(linkedWorkItem
? {
linkedWorkItem,
prefilledName:
getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ??
getLinkedWorkItemSuggestedName({ title: linkedWorkItem.title })
}
: { prefilledName: trimmed }),
telemetrySource: 'command_palette'
})
)
})
.catch(() => {
if (createLookupGuard.isCurrent(token)) {
queueMicrotask(() =>
openModal('new-workspace-composer', {
initialRepoId: repo.id,
prefilledName: trimmed,
telemetrySource: 'command_palette'
})
)
}
})
return
}
openComposer(trimmed ? { prefilledName: trimmed } : {})
// oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities.
}, [
allWorktrees,
closeModal,
createLookupGuard,
createWorktreeName,
currentGitHubWorkItemPreview,
currentLinearIssuePreview,
focusFallbackSurface,
githubLookupRef,
linearIssueUrlIntent,
linearLookupRef,
openModal,
prefetchCreateWorkspaceBaseForComposer,
query,
recordFeatureInteraction,
repoMap
repoMap,
selectionMovedByUserRef,
skipRestoreFocusRef,
taskSourceUrl,
taskUrlCreatePreview
])
const handleCloseAutoFocus = useCallback((event: Event) => event.preventDefault(), [])
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the controller ref preserves its original stable identity.
const focusPaletteInput = useCallback(() => inputRef.current?.focus(), [])
const setDialogElementFromNode = useCallback((node: HTMLDivElement | null) => {
setDialogElement(node?.closest<HTMLElement>('[role="dialog"]') ?? null)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- local-state setter identity is stable across extraction.
}, [])
const handleOpenAutoFocus = useCallback((_event: Event) => {
// No-op: focus is captured before Radix moves it into the dialog.
}, [])
const focusPaletteInput = useCallback(() => inputRef.current?.focus(), [inputRef])
const setDialogElementFromNode = useCallback(
(node: HTMLDivElement | null) =>
setDialogElement(node?.closest<HTMLElement>('[role="dialog"]') ?? null),
[setDialogElement]
)
const handleOpenAutoFocus = useCallback((_event: Event) => {}, [])
return {
handleCreateWorktree,
handleCloseAutoFocus,
@@ -222,4 +321,6 @@ export function useWorktreeJumpPaletteCreateAction({
}
}
export type WorktreeJumpPaletteCreateAction = ReturnType<typeof useWorktreeJumpPaletteCreateAction>
export type WorktreeJumpPaletteCreateAction = ReturnType<
typeof useWorktreeJumpPaletteCreateAction
>
@@ -10,23 +10,25 @@ import {
type PaletteItem,
type PaletteListEntry
} from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteOpenTabs } from './use-worktree-jump-palette-open-tabs'
import { buildPaletteListEntryRenderKeys } from '@/components/cmd-j/palette-list-entry-render-keys'
import type { WorktreeJumpPaletteSections } from './use-worktree-jump-palette-sections'
import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
type WorktreeJumpPaletteListEntriesInput = WorktreeJumpPaletteSections &
Pick<WorktreeJumpPaletteWorktrees, 'hasQuery'> &
Pick<WorktreeJumpPaletteOpenTabs, 'worktreeItems'> &
Pick<WorktreeJumpPaletteLocalState, 'autoSelectedItemIdRef'>
Pick<WorktreeJumpPaletteLocalState, 'autoSelectedItemIdRef' | 'taskSourceUrl'> &
Pick<WorktreeJumpPaletteSections, 'middleLeadsSections' | 'handleExpandSection'>
export function useWorktreeJumpPaletteListEntries({
hasQuery,
openTabsLeadSections,
paletteSections,
showCreateAction,
worktreeItems,
autoSelectedItemIdRef
autoSelectedItemIdRef,
taskSourceUrl,
handleExpandSection,
middleLeadsSections
}: WorktreeJumpPaletteListEntriesInput) {
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
@@ -35,7 +37,6 @@ export function useWorktreeJumpPaletteListEntries({
visibleProjectTargetItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint,
worktreeOverflowCount,
projectTargetOverflowCount,
middleOverflowCount,
@@ -43,16 +44,19 @@ export function useWorktreeJumpPaletteListEntries({
multiPrimaryFirstScreen,
multiPrimaryLayout
} = paletteSections
const pushOverflowHint = (id: string, overflowCount: number): void => {
const pushOverflowHint = (
id: string,
overflowCount: number,
onSeeMore?: () => void
): void => {
if (overflowCount > 0) {
entries.push({
id,
type: 'hint',
label: translate(
'worktreeJumpPalette.renderCapOverflow',
'{{value0}} more — scroll or keep typing to narrow',
{ value0: overflowCount }
)
label: translate('worktreeJumpPalette.renderCapOverflow', '{{value0}} more', {
value0: overflowCount
}),
onSeeMore
})
}
}
@@ -96,18 +100,9 @@ export function useWorktreeJumpPaletteListEntries({
}
pushWorktreesHeader()
appendPaletteListEntries(entries, visibleWorktreeItems)
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 }
)
})
}
pushOverflowHint('__hint_worktree_overflow__', worktreeOverflowCount)
pushOverflowHint('__hint_worktree_overflow__', worktreeOverflowCount, () =>
handleExpandSection('worktrees')
)
}
const pushOpenTabSection = (): void => {
if (visibleOpenTabItems.length === 0) {
@@ -115,7 +110,9 @@ export function useWorktreeJumpPaletteListEntries({
}
pushOpenTabsHeader()
appendPaletteListEntries(entries, visibleOpenTabItems)
pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount)
pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount, () =>
handleExpandSection('open-tabs')
)
}
const pushProjectAndMiddleSections = (): void => {
if (visibleProjectTargetItems.length > 0) {
@@ -130,7 +127,9 @@ export function useWorktreeJumpPaletteListEntries({
})
}
appendPaletteListEntries(entries, visibleProjectTargetItems)
pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount)
pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount, () =>
handleExpandSection('projects')
)
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
@@ -141,15 +140,30 @@ export function useWorktreeJumpPaletteListEntries({
})
}
appendPaletteListEntries(entries, visibleMiddleItems)
pushOverflowHint('__hint_middle_overflow__', middleOverflowCount)
pushOverflowHint('__hint_middle_overflow__', middleOverflowCount, () =>
handleExpandSection('middle')
)
}
}
// Why: a pasted issue/PR URL is decisive. Show linked worktrees first so
// Enter jumps; keep create available underneath when the user wants a new one.
if (taskSourceUrl) {
if (visibleWorktreeItems.length > 0) {
pushWorktreeSection()
}
if (showCreateAction) {
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
return entries
}
if (!hasQuery) {
pushOpenTabSection()
pushWorktreeSection()
return entries
}
if (multiPrimaryFirstScreen && multiPrimaryLayout) {
const leadingSectionKey = openTabsLeadSections ? 'open-tabs' : 'worktrees'
const trailingSectionKey = openTabsLeadSections ? 'worktrees' : 'open-tabs'
const leadingHintId = openTabsLeadSections
? '__hint_open_tab_overflow__'
: '__hint_worktree_overflow__'
@@ -172,13 +186,18 @@ export function useWorktreeJumpPaletteListEntries({
}
pushLeadingHeader()
appendPaletteListEntries(entries, multiPrimaryLayout.leadingPreview as PaletteItem[])
pushOverflowHint(leadingHintId, multiPrimaryLayout.leadingMoreCount)
pushOverflowHint(leadingHintId, multiPrimaryLayout.leadingMoreCount, () =>
handleExpandSection(openTabsLeadSections ? 'open-tabs' : 'worktrees')
)
pushTrailingHeader()
appendPaletteListEntries(entries, multiPrimaryLayout.trailingFloor as PaletteItem[])
const hasLeadingRest = multiPrimaryLayout.leadingRest.length > 0
if (hasLeadingRest) {
pushLeadingHeader(CONTINUED_SECTION_HEADER_ID_SUFFIX)
appendPaletteListEntries(entries, multiPrimaryLayout.leadingRest as PaletteItem[])
pushOverflowHint(`${leadingHintId}_tail`, multiPrimaryLayout.leadingHardOverflowCount, () =>
handleExpandSection(leadingSectionKey)
)
}
if (multiPrimaryLayout.trailingRest.length > 0) {
if (hasLeadingRest) {
@@ -186,18 +205,25 @@ export function useWorktreeJumpPaletteListEntries({
}
appendPaletteListEntries(entries, multiPrimaryLayout.trailingRest as PaletteItem[])
}
pushOverflowHint(trailingHintId, multiPrimaryLayout.trailingHardOverflowCount)
pushOverflowHint(trailingHintId, multiPrimaryLayout.trailingHardOverflowCount, () =>
handleExpandSection(trailingSectionKey)
)
pushProjectAndMiddleSections()
if (showCreateAction) {
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
return entries
}
if (middleLeadsSections) {
pushProjectAndMiddleSections()
}
if (openTabsLeadSections) {
pushOpenTabSection()
}
pushWorktreeSection()
pushProjectAndMiddleSections()
if (!middleLeadsSections) {
pushProjectAndMiddleSections()
}
if (!openTabsLeadSections) {
pushOpenTabSection()
}
@@ -205,7 +231,16 @@ export function useWorktreeJumpPaletteListEntries({
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
return entries
}, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length])
}, [
handleExpandSection,
hasQuery,
middleLeadsSections,
openTabsLeadSections,
paletteSections,
showCreateAction,
taskSourceUrl,
])
const selectableItems = useMemo<PaletteItem[]>(
() =>
listEntries.filter(
@@ -216,15 +251,19 @@ export function useWorktreeJumpPaletteListEntries({
),
[listEntries]
)
const selectionItemIds = useMemo(
() => getWorktreePaletteSelectionItemIds(listEntries),
const listEntryRenderKeys = useMemo(
() => buildPaletteListEntryRenderKeys(listEntries.map((entry) => entry.id)),
[listEntries]
)
const selectionItemIds = useMemo(
() => getWorktreePaletteSelectionItemIds(listEntries, listEntryRenderKeys),
[listEntries, listEntryRenderKeys]
)
useEffect(() => {
autoSelectedItemIdRef.current = selectionItemIds[0] ?? null
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the controller ref preserves its original stable identity.
}, [selectionItemIds])
return { listEntries, selectableItems, selectionItemIds }
return { listEntries, listEntryRenderKeys, selectableItems, selectionItemIds }
}
export type WorktreeJumpPaletteListEntries = ReturnType<typeof useWorktreeJumpPaletteListEntries>
@@ -1,20 +1,39 @@
import { useDeferredValue, useRef, useState } from 'react'
import { useDeferredValue, useMemo, useRef, useState } from 'react'
import type { WorktreePaletteRequestGuard } from '@/lib/worktree-palette-create-action'
import { EMPTY_PALETTE_FILTER, type PaletteFilterState } from '@/components/cmd-j/palette-filter'
import {
EMPTY_PALETTE_FILTER,
type PaletteFilterState
} from '@/components/cmd-j/palette-filter'
import { parseCmdJTaskSourceUrl } from '@/lib/worktree-palette-task-url-match'
import { getWorktreePaletteCreateActionState } from '@/lib/worktree-palette-create-action'
import type { CmdJActiveGroupSnapshot } from '@/components/cmd-j/quick-action-context'
import type { WorkspaceVisibleTabType } from '../../../shared/tab-types'
import type { PaletteItem } from './worktree-jump-palette-model'
export function useWorktreeJumpPaletteLocalState({
createLookupGuard
createLookupGuard,
visible
}: {
createLookupGuard: WorktreePaletteRequestGuard
visible: boolean
}) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const liveQueryRef = useRef(query)
liveQueryRef.current = query
const taskSourceUrl = useMemo(() => parseCmdJTaskSourceUrl(query), [query])
const paletteSearchQuery = taskSourceUrl ? query.trim() : deferredQuery.trim()
const deferredCreateAction = useMemo(
() => getWorktreePaletteCreateActionState({ query: deferredQuery }),
[deferredQuery]
)
const createWorktreeName = taskSourceUrl ? query.trim() : deferredCreateAction.createWorktreeName
const showCreateAction = deferredCreateAction.showCreateAction || taskSourceUrl !== null
const [selectedItemId, setSelectedItemId] = useState('')
const latestQueryRef = useRef('')
const autoSelectedItemIdRef = useRef<string | null>(null)
// Create is armed by an explicit keyboard/pointer move, except for task URLs.
const selectionMovedByUserRef = useRef(false)
const digitShortcutItemsRef = useRef<readonly PaletteItem[]>([])
const [rawFilter, setRawFilter] = useState<PaletteFilterState>(EMPTY_PALETTE_FILTER)
const [dialogElement, setDialogElement] = useState<HTMLElement | null>(null)
@@ -31,15 +50,31 @@ export function useWorktreeJumpPaletteLocalState({
const fallbackFocusOuterFrameRef = useRef<number | null>(null)
const fallbackFocusInnerFrameRef = useRef<number | null>(null)
const preserveCreateLookupOnCloseRef = useRef(false)
const [expandedSectionCaps, setExpandedSectionCaps] = useState<Record<string, number>>({})
// Reset expansion after a new query or a fresh open without adding an extra effect render.
const [previousQuery, setPreviousQuery] = useState(query)
const [previousVisible, setPreviousVisible] = useState(visible)
if (previousQuery !== query || previousVisible !== visible) {
setPreviousQuery(query)
setPreviousVisible(visible)
setExpandedSectionCaps({})
}
return {
query,
setQuery,
deferredQuery,
liveQueryRef,
taskSourceUrl,
paletteSearchQuery,
createWorktreeName,
showCreateAction,
selectedItemId,
setSelectedItemId,
latestQueryRef,
autoSelectedItemIdRef,
selectionMovedByUserRef,
digitShortcutItemsRef,
rawFilter,
setRawFilter,
@@ -58,7 +93,10 @@ export function useWorktreeJumpPaletteLocalState({
fallbackFocusOuterFrameRef,
fallbackFocusInnerFrameRef,
createLookupGuard,
preserveCreateLookupOnCloseRef
preserveCreateLookupOnCloseRef,
expandedSectionCaps,
setExpandedSectionCaps,
previousVisible
}
}
@@ -11,11 +11,8 @@ import {
searchWorkspaceTabs,
type SearchableWorkspaceTab
} from '@/lib/workspace-tab-palette-search'
import {
getOpenTabMatchRelevance,
getWorktreeMatchRelevance,
NO_MATCH_RELEVANCE
} from '@/lib/cmd-j-match-relevance'
import { comparePaletteRankedItems } from '@/lib/cmd-j-section-leadership'
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import type {
BrowserPaletteItem,
OpenTabPaletteItem,
@@ -34,18 +31,21 @@ const EMPTY_WORKSPACE_TAB_ENTRIES: SearchableWorkspaceTab[] = []
type WorktreeJumpPaletteOpenTabsInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteWorktrees &
Pick<WorktreeJumpPaletteFilter, 'repoMap'> &
Pick<WorktreeJumpPaletteFilter, 'repoMap' | 'repoByHostIdentity'> &
Pick<WorktreeJumpPaletteLocalState, 'deferredQuery'>
export function useWorktreeJumpPaletteOpenTabs({
paletteStatusInputsActive,
browserSortedWorktrees,
allWorktrees,
repoMap,
repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
@@ -61,10 +61,12 @@ export function useWorktreeJumpPaletteOpenTabs({
activeFileIdByWorktree,
activeTabTypeByWorktree,
settings,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey,
deferredQuery,
hasQuery,
worktreeMatches,
worktreeMap
resolveWorktree
}: WorktreeJumpPaletteOpenTabsInput) {
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
if (!paletteStatusInputsActive) {
@@ -72,12 +74,15 @@ export function useWorktreeJumpPaletteOpenTabs({
}
return buildSearchableBrowserPages({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
})
}, [
@@ -85,9 +90,12 @@ export function useWorktreeJumpPaletteOpenTabs({
activeBrowserTabId,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserPagesByWorkspace,
browserTabsByWorktree,
browserSortedWorktrees,
repoByHostIdentity,
repoMap,
worktreeOrder
])
@@ -101,12 +109,15 @@ export function useWorktreeJumpPaletteOpenTabs({
}
return buildSearchableSimulatorTabs({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
})
}, [
@@ -114,8 +125,11 @@ export function useWorktreeJumpPaletteOpenTabs({
activeGroupIdByWorktree,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserSortedWorktrees,
groupsByWorktree,
repoByHostIdentity,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
@@ -130,7 +144,9 @@ export function useWorktreeJumpPaletteOpenTabs({
}
return buildSearchableWorkspaceTabs({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
tabsByWorktree,
@@ -141,13 +157,16 @@ export function useWorktreeJumpPaletteOpenTabs({
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
generatedTitlesEnabled: settings?.tabAutoGenerateTitle === true
generatedTitlesEnabled: settings?.tabAutoGenerateTitle === true,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey
})
}, [
paletteStatusInputsActive,
@@ -159,15 +178,20 @@ export function useWorktreeJumpPaletteOpenTabs({
activeTabType,
activeTabTypeByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
agentStatusByPaneKey,
browserSortedWorktrees,
groupsByWorktree,
openFiles,
repoMap,
repoByHostIdentity,
retainedAgentsByPaneKey,
settings?.tabAutoGenerateTitle,
sleepingAgentSessionsByPaneKey,
paneForegroundAgentByPaneKey,
tabsByWorktree,
terminalLayoutsByTabId,
unifiedTabsByWorktree,
worktreeOrder
])
@@ -175,30 +199,10 @@ export function useWorktreeJumpPaletteOpenTabs({
() => searchWorkspaceTabs(workspaceTabEntries, deferredQuery.trim()),
[workspaceTabEntries, deferredQuery]
)
const worktreeRelevanceById = useMemo(() => {
const relevanceById = new Map<string, number>()
if (!hasQuery) {
return relevanceById
}
for (const match of worktreeMatches) {
const worktree = worktreeMap.get(match.worktreeId)
if (worktree) {
relevanceById.set(
match.worktreeId,
getWorktreeMatchRelevance(
match,
worktree,
repoMap.get(worktree.repoId)?.displayName ?? ''
)
)
}
}
return relevanceById
}, [hasQuery, repoMap, worktreeMap, worktreeMatches])
const worktreeItems = useMemo<WorktreePaletteItem[]>(() => {
const items = worktreeMatches
.map((match) => {
const worktree = worktreeMap.get(match.worktreeId)
const worktree = resolveWorktree(match.worktreeId, match.worktreeHostId)
return worktree
? { id: `worktree:${worktree.id}`, type: 'worktree' as const, match, worktree }
: null
@@ -207,12 +211,24 @@ export function useWorktreeJumpPaletteOpenTabs({
if (!hasQuery) {
return items
}
return items.sort(
(left, right) =>
(worktreeRelevanceById.get(left.worktree.id) ?? NO_MATCH_RELEVANCE) -
(worktreeRelevanceById.get(right.worktree.id) ?? NO_MATCH_RELEVANCE)
const orderByIdentity = new Map(
items.map((item, index) => [getWorktreeHostIdentity(item.worktree), index])
)
}, [hasQuery, worktreeMap, worktreeMatches, worktreeRelevanceById])
return items.sort((left, right) =>
comparePaletteRankedItems(
{
rank: left.match.rank,
order: orderByIdentity.get(getWorktreeHostIdentity(left.worktree)) ?? 0,
id: left.id
},
{
rank: right.match.rank,
order: orderByIdentity.get(getWorktreeHostIdentity(right.worktree)) ?? 0,
id: right.id
}
)
)
}, [hasQuery, resolveWorktree, worktreeMatches])
const browserItems = useMemo<BrowserPaletteItem[]>(
() =>
browserMatches.map((result) => ({
@@ -242,28 +258,28 @@ export function useWorktreeJumpPaletteOpenTabs({
)
const openTabItems = useMemo<OpenTabPaletteItem[]>(() => {
const items = [...browserItems, ...simulatorItems, ...workspaceTabItems]
const relevanceById = new Map(
items.map((item) => [item.id, getOpenTabMatchRelevance(item.result)])
return items.sort((left, right) =>
comparePaletteRankedItems(
{
rank: left.result.rank,
order: left.result.score,
id: left.id,
lastActiveAt: left.result.lastActiveAt ?? undefined
},
{
rank: right.result.rank,
order: right.result.score,
id: right.id,
lastActiveAt: right.result.lastActiveAt ?? undefined
}
)
)
return items.sort((left, right) => {
const relevance =
(relevanceById.get(left.id) ?? NO_MATCH_RELEVANCE) -
(relevanceById.get(right.id) ?? NO_MATCH_RELEVANCE)
if (relevance !== 0) {
return relevance
}
if (left.result.score !== right.result.score) {
return left.result.score - right.result.score
}
return left.id.localeCompare(right.id)
})
}, [browserItems, simulatorItems, workspaceTabItems])
return {
browserPageEntries,
simulatorTabEntries,
workspaceTabEntries,
worktreeRelevanceById,
worktreeItems,
browserItems,
simulatorItems,
@@ -28,6 +28,7 @@ export function useWorktreeJumpPaletteProjectTargets({
repos,
worktreesByRepo,
detectedWorktreesByRepo,
worktreeVisibilityDefaultsByHost,
pendingWorktreeCreations,
projectGroups,
projects,
@@ -63,8 +64,9 @@ export function useWorktreeJumpPaletteProjectTargets({
}
}
for (const repoId of buildImportedWorktreesCardCandidates({
repos,
detectedWorktreesByRepo
repos,
detectedWorktreesByRepo,
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
}).keys()) {
ids.add(repoId)
}
@@ -72,7 +74,14 @@ export function useWorktreeJumpPaletteProjectTargets({
ids.add(creation.request.repoId)
}
return ids
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
}, [
allWorktrees,
detectedWorktreesByRepo,
pendingWorktreeCreations,
repos,
worktreeVisibilityDefaultsByHost,
worktreesByRepo
])
const hasAnyProjectSearchCandidates = useMemo(
() =>
hasCmdJProjectSearchCandidates({
@@ -8,6 +8,7 @@ import {
import { rankCmdJMiddleResults } from '@/components/cmd-j/palette-results'
import {
getComposerEligibleRepos,
resolveComposerActiveRepoId,
resolveComposerGitRepoId
} from '@/lib/new-workspace-composer-repo'
import type { QuickActionPaletteItem, SettingsPaletteItem } from './worktree-jump-palette-model'
@@ -24,7 +25,7 @@ function getComposerPrefetchRepoId(
return resolveComposerGitRepoId({
eligibleRepos: getComposerEligibleRepos(state.repos),
initialRepoId,
activeRepoId: state.activeRepoId,
activeRepoId: resolveComposerActiveRepoId(state.repos, getComposerEligibleRepos(state.repos), state.activeRepoId),
focusedHostScope: state.workspaceHostScope
})
}
@@ -71,11 +72,20 @@ export function useWorktreeJumpPaletteQuickActions({
)
}, [openModal, prefetchCreateWorkspaceBaseForComposer])
const deleteActiveWorkspaceAction = useCallback(() => {
const { activeView: currentView, activeWorktreeId: currentWorktreeId } = useAppStore.getState()
const {
activeView: currentView,
activeWorktreeId: currentWorktreeId,
activeWorkspaceExecutionHostId
} = useAppStore.getState()
if (currentView !== 'terminal' || !currentWorktreeId) {
return
}
queueMicrotask(() => runWorktreeDelete(currentWorktreeId))
queueMicrotask(() =>
runWorktreeDelete(
currentWorktreeId,
activeWorkspaceExecutionHostId ? { expectedHostId: activeWorkspaceExecutionHostId } : {}
)
)
}, [])
const openAddQuickCommandAction = useCallback(() => {
openSettingsTarget({ pane: 'quick-commands', repoId: null, intent: 'add-quick-command' })
@@ -23,10 +23,43 @@ import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-w
type WorktreeJumpPaletteRecentTabsInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteOpenTabs &
Pick<WorktreeJumpPaletteWorktrees, 'worktreeMap' | 'hasQuery'> &
Pick<WorktreeJumpPaletteWorktrees, 'resolveWorktree' | 'hasQuery'> &
Pick<WorktreeJumpPaletteFilter, 'filterActive'> &
Pick<WorktreeJumpPaletteLocalState, 'query' | 'autoSelectedItemIdRef' | 'setSelectedItemId'>
function getRecentTabOccurrenceBase(item: OpenTabRecentRow['item']): string {
if (item.type === 'browser-page') {
const result = item.result
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.workspaceId,
result.pageId
])
}
if (item.type === 'simulator-tab') {
const result = item.result
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.tabId
])
}
const result = item.result
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.tabId,
result.entityId
])
}
export function useWorktreeJumpPaletteRecentTabs({
tabsByWorktree,
agentStatusByPaneKey,
@@ -35,7 +68,7 @@ export function useWorktreeJumpPaletteRecentTabs({
runtimePaneTitlesByTabId,
terminalLayoutsByTabId,
openTabItems,
worktreeMap,
resolveWorktree,
unreadTerminalTabs,
unreadAgentCompletionPanes,
visible,
@@ -48,6 +81,15 @@ export function useWorktreeJumpPaletteRecentTabs({
autoSelectedItemIdRef,
setSelectedItemId
}: WorktreeJumpPaletteRecentTabsInput) {
const occurrenceIds = useMemo(() => {
const counts = new Map<string, number>()
return openTabItems.map((item) => {
const base = getRecentTabOccurrenceBase(item)
const ordinal = counts.get(base) ?? 0
counts.set(base, ordinal + 1)
return `recent-tab:${base}:${ordinal}`
})
}, [openTabItems])
const terminalTabsById = useMemo(() => {
const byId = new Map<string, TerminalTab>()
for (const tabs of Object.values(tabsByWorktree)) {
@@ -77,17 +119,21 @@ export function useWorktreeJumpPaletteRecentTabs({
)
const openTabRecentRows = useMemo<OpenTabRecentRow[]>(() => {
const entries: OpenTabRecentRow[] = []
for (const item of openTabItems) {
const worktree = worktreeMap.get(item.result.worktreeId)
for (const [index, item] of openTabItems.entries()) {
const worktree = resolveWorktree(item.result.worktreeId, item.result.executionHostId)
if (!worktree) {
continue
}
const occurrenceId = occurrenceIds[index]!
entries.push({
item,
occurrenceId,
worktree,
row: {
id: item.id,
occurrenceId,
worktreeId: worktree.id,
worktreeHostId: worktree.hostId,
unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId,
terminalTab:
item.type === 'workspace-tab' && item.result.contentType === 'terminal'
@@ -98,9 +144,9 @@ export function useWorktreeJumpPaletteRecentTabs({
})
}
return entries
}, [openTabItems, terminalTabsById, worktreeMap])
const recentTabRowById = useMemo(
() => new Map(openTabRecentRows.map(({ row }) => [row.id, row])),
}, [occurrenceIds, openTabItems, resolveWorktree, terminalTabsById])
const recentTabRowByItem = useMemo(
() => new Map(openTabRecentRows.map(({ item, row }) => [item, row])),
[openTabRecentRows]
)
const recentTabRows = useMemo<RecentWorkspaceTabRow[]>(() => {
@@ -190,11 +236,13 @@ export function useWorktreeJumpPaletteRecentTabs({
visible
])
const recentTabItems = useMemo<PaletteItem[]>(() => {
const itemById = new Map(openTabItems.map((item) => [item.id, item]))
return recentTabOrder.flatMap((id) => itemById.get(id) ?? [])
}, [openTabItems, recentTabOrder])
const itemByOccurrenceId = new Map(
openTabRecentRows.map(({ occurrenceId, item }) => [occurrenceId, item])
)
return recentTabOrder.flatMap((occurrenceId) => itemByOccurrenceId.get(occurrenceId) ?? [])
}, [openTabRecentRows, recentTabOrder])
return { recentTabPaneSources, recentTabRowById, recentTabItems }
return { recentTabPaneSources, recentTabRowByItem, recentTabItems, openTabRecentRows }
}
export type WorktreeJumpPaletteRecentTabs = ReturnType<typeof useWorktreeJumpPaletteRecentTabs>
@@ -1,10 +1,18 @@
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { getOpenTabMatchRelevance, NO_MATCH_RELEVANCE } from '@/lib/cmd-j-match-relevance'
import { getWorktreePaletteCreateActionState } from '@/lib/worktree-palette-create-action'
import { bestCmdJPaletteSectionQualityClass } from '@/components/cmd-j/palette-results'
import {
bestPaletteQualityRank,
NO_PALETTE_QUALITY_RANK,
shouldIntentSectionLeadPaletteSections,
shouldOpenTabsLeadPaletteSections
} from '@/lib/cmd-j-section-leadership'
import {
capPaletteSection,
layoutMultiPrimaryPaletteSections
layoutMultiPrimaryPaletteSections,
PALETTE_SECTION_EXPAND_STEP,
PALETTE_SECTION_RENDER_CAP,
TYPED_QUERY_LEADING_PREVIEW
} from '@/components/cmd-j/palette-section-render-cap'
import {
DIGIT_INDEX_ACTION_ID,
@@ -28,56 +36,108 @@ type WorktreeJumpPaletteSectionsInput = WorktreeJumpPaletteOpenTabs &
WorktreeJumpPaletteProjectTargets &
Pick<WorktreeJumpPaletteQuickActions, 'middleItems'> &
Pick<WorktreeJumpPaletteWorktrees, 'hasQuery'> &
Pick<WorktreeJumpPaletteLocalState, 'deferredQuery'>
Pick<
WorktreeJumpPaletteLocalState,
'createWorktreeName' | 'showCreateAction' | 'expandedSectionCaps' | 'setExpandedSectionCaps'
>
export function useWorktreeJumpPaletteSections({
hasQuery,
worktreeItems,
worktreeRelevanceById,
openTabItems,
recentTabItems,
projectTargetItems,
middleItems,
deferredQuery
createWorktreeName,
showCreateAction,
expandedSectionCaps,
setExpandedSectionCaps
}: WorktreeJumpPaletteSectionsInput) {
const openTabsLeadSections = useMemo(() => {
if (!hasQuery) {
return true
}
const bestWorktree = worktreeItems[0]
const bestWorktreeRelevance = bestWorktree
? (worktreeRelevanceById.get(bestWorktree.worktree.id) ?? NO_MATCH_RELEVANCE)
: NO_MATCH_RELEVANCE
const bestOpenTab = openTabItems[0]
const bestOpenTabRelevance = bestOpenTab
? getOpenTabMatchRelevance(bestOpenTab.result)
: NO_MATCH_RELEVANCE
return bestOpenTabRelevance <= bestWorktreeRelevance
}, [hasQuery, openTabItems, worktreeItems, worktreeRelevanceById])
if (!hasQuery) return true
return shouldOpenTabsLeadPaletteSections({
bestWorktreeQualityRank: worktreeItems[0]
? bestPaletteQualityRank([worktreeItems[0].match.qualityClass])
: NO_PALETTE_QUALITY_RANK,
bestOpenTabQualityRank: openTabItems[0]
? bestPaletteQualityRank([openTabItems[0].result.qualityClass])
: NO_PALETTE_QUALITY_RANK
})
}, [hasQuery, openTabItems, worktreeItems])
const middleLeadsSections = useMemo(() => {
if (!hasQuery) return false
const bestEntityQualityRank = Math.min(
worktreeItems[0]
? bestPaletteQualityRank([worktreeItems[0].match.qualityClass])
: NO_PALETTE_QUALITY_RANK,
openTabItems[0]
? bestPaletteQualityRank([openTabItems[0].result.qualityClass])
: NO_PALETTE_QUALITY_RANK
)
return shouldIntentSectionLeadPaletteSections({
bestEntityQualityRank,
bestIntentQualityRank: bestPaletteQualityRank([
bestCmdJPaletteSectionQualityClass(middleItems.map((item) => item.result)),
bestCmdJPaletteSectionQualityClass(projectTargetItems.map((item) => item.result))
])
})
}, [hasQuery, middleItems, openTabItems, projectTargetItems, worktreeItems])
const handleExpandSection = useCallback(
(sectionKey: string) => {
setExpandedSectionCaps((previous) => ({
...previous,
[sectionKey]: (previous[sectionKey] ?? 0) + PALETTE_SECTION_EXPAND_STEP
}))
},
[setExpandedSectionCaps]
)
const paletteSections = useMemo(() => {
// Why: the empty-query trim lives here, not in `recentTabItems`, so the trimmed tail becomes an
// overflow count the section can offer as "See more" instead of silently vanishing.
const openTabsCap = PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps['open-tabs'] ?? 0)
// Why: "See more" drops the above-the-fold trim outright instead of stepping 20 at a time, so one
// click reveals the whole recent history the shared render cap allows.
const recentTabsCap = expandedSectionCaps['open-tabs']
? openTabsCap
: EMPTY_QUERY_RECENT_TAB_CAP
const openTabs = hasQuery
? capPaletteSection(openTabItems)
: capPaletteSection(recentTabItems, EMPTY_QUERY_RECENT_TAB_CAP)
const worktreeCap = hasQuery
? capPaletteSection(openTabItems, openTabsCap)
: capPaletteSection(recentTabItems, recentTabsCap)
const baseWorktreeCap = hasQuery
? Infinity
: Math.min(
openTabs.visible.length === 0 ? EMPTY_QUERY_ROW_BUDGET : EMPTY_QUERY_WORKTREE_CAP,
Math.max(1, EMPTY_QUERY_ROW_BUDGET - openTabs.visible.length)
)
const worktreeCap = hasQuery
? PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps.worktrees ?? 0)
: baseWorktreeCap + (expandedSectionCaps.worktrees ?? 0)
const worktrees = hasQuery
? capPaletteSection(worktreeItems)
: { visible: worktreeItems.slice(0, worktreeCap), overflowCount: 0 }
const projectTargets = capPaletteSection(hasQuery ? projectTargetItems : [])
const middle = capPaletteSection(hasQuery ? middleItems : [])
const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap
? capPaletteSection(worktreeItems, worktreeCap)
: {
visible: worktreeItems.slice(0, worktreeCap),
overflowCount: Math.max(0, worktreeItems.length - worktreeCap)
}
const projectTargets = capPaletteSection(
hasQuery ? projectTargetItems : [],
PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps.projects ?? 0)
)
const middle = capPaletteSection(
hasQuery ? middleItems : [],
PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps.middle ?? 0)
)
const multiPrimaryFirstScreen =
hasQuery && openTabs.visible.length > 0 && worktrees.visible.length > 0
const multiPrimaryLayout = multiPrimaryFirstScreen
? layoutMultiPrimaryPaletteSections<WorktreePaletteItem | OpenTabPaletteItem>({
leadingItems: openTabsLeadSections ? openTabItems : worktreeItems,
trailingItems: openTabsLeadSections ? worktreeItems : openTabItems
trailingItems: openTabsLeadSections ? worktreeItems : openTabItems,
leadingPreviewCount:
TYPED_QUERY_LEADING_PREVIEW +
(expandedSectionCaps[openTabsLeadSections ? 'open-tabs' : 'worktrees'] ?? 0),
leadingHardCap: openTabsLeadSections ? openTabsCap : worktreeCap,
trailingHardCap: openTabsLeadSections ? worktreeCap : openTabsCap
})
: null
return {
@@ -89,21 +149,33 @@ export function useWorktreeJumpPaletteSections({
middleOverflowCount: middle.overflowCount,
visibleOpenTabItems: openTabs.visible as PaletteItem[],
openTabOverflowCount: openTabs.overflowCount,
showWorktreeHint,
multiPrimaryFirstScreen,
multiPrimaryLayout
}
}, [
worktreeItems,
projectTargetItems,
expandedSectionCaps,
hasQuery,
middleItems,
openTabItems,
openTabsLeadSections,
projectTargetItems,
recentTabItems,
hasQuery,
openTabsLeadSections
worktreeItems
])
// Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query, and an
// expanded section leaves its unaddressable rows unbadged rather than advertising ⌘10.
const recentTabShortcutIndexByItem = useMemo(
() =>
new Map(
hasQuery
? []
: paletteSections.visibleOpenTabItems
.slice(0, DIGIT_INDEX_ADDRESSABLE_ROWS)
.map((item, index) => [item, index])
),
[hasQuery, paletteSections]
)
const recentTabShortcutIndexById = useMemo(
() =>
new Map(
@@ -117,17 +189,17 @@ export function useWorktreeJumpPaletteSections({
)
const digitShortcutModifiers =
useShortcutKeyComboDetails(DIGIT_INDEX_ACTION_ID)[0]?.keys.slice(0, -1) ?? []
const { createWorktreeName, showCreateAction } = useMemo(
() => getWorktreePaletteCreateActionState({ query: deferredQuery }),
[deferredQuery]
)
return {
openTabsLeadSections,
middleLeadsSections,
paletteSections,
recentTabShortcutIndexByItem,
recentTabShortcutIndexById,
digitShortcutModifiers,
createWorktreeName,
showCreateAction
showCreateAction,
handleExpandSection
}
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useLayoutEffect } from 'react'
import {
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
queueBrowserFocusRequest
@@ -57,6 +57,8 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
setQuery,
setSelectedItemId,
setRawFilter,
selectionMovedByUserRef,
taskSourceUrl,
listRef,
preserveCreateLookupOnCloseRef,
selectedItemId,
@@ -105,6 +107,7 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
latestQueryRef.current = ''
setQuery('')
setSelectedItemId('')
selectionMovedByUserRef.current = false
setRawFilter(EMPTY_PALETTE_FILTER)
listRef.current?.scrollTo(0, 0)
}
@@ -131,18 +134,29 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
currentSelectedItemId: selectedItemId,
queryChanged: false,
selectableItemIds: selectionItemIds,
showCreateAction
showCreateAction,
autoSelectCreateAction: taskSourceUrl !== null
})
const handleCommandSelectionChange = useCallback(
(nextItemId: string) => {
if (latestQueryRef.current !== deferredQuery) {
return
}
setSelectedItemId(nextItemId)
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs and setters preserve their original stable identities.
[deferredQuery]
[setSelectedItemId]
)
// A late cmdk callback can restore the old cursor after handleQueryChange clears it.
// Commit the new list head explicitly when the deferred query changes.
useLayoutEffect(() => {
setSelectedItemId(
getNextWorktreePaletteSelection({
currentSelectedItemId: '',
queryChanged: true,
selectableItemIds: selectionItemIds,
showCreateAction,
autoSelectCreateAction: taskSourceUrl !== null
})
)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- selection resets only when the deferred query commits.
}, [deferredQuery])
useEffect(() => {
const isCreateWorkspaceHighlighted =
commandSelectedItemId === CREATE_WORKTREE_ITEM_ID ||
@@ -19,6 +19,9 @@ export function useWorktreeJumpPaletteStoreState({
lingering: boolean
}) {
useTranslation()
// Freeze age labels for one palette session; live status dots own their clock separately.
// oxlint-disable-next-line react/purity
const paletteNowMs = useMemo(() => Date.now(), [visible])
const closeModal = useAppStore((state) => state.closeModal)
const openModal = useAppStore((state) => state.openModal)
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
@@ -44,6 +47,9 @@ export function useWorktreeJumpPaletteStoreState({
const migrationUnsupportedByPtyId = useAppStore((state) => state.migrationUnsupportedByPtyId)
const activeView = useAppStore((state) => state.activeView)
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const activeWorkspaceExecutionHostId = useAppStore(
(state) => state.activeWorkspaceExecutionHostId
)
const activeTabType = useAppStore((state) => state.activeTabType)
const activeTabId = useAppStore((state) => state.activeTabId)
const activeTabIdByWorktree = useAppStore((state) => state.activeTabIdByWorktree)
@@ -72,7 +78,13 @@ export function useWorktreeJumpPaletteStoreState({
const sleepingAgentSessionsByPaneKey = useAppStore(
(state) => state.sleepingAgentSessionsByPaneKey
)
const paneForegroundAgentByPaneKey = useAppStore(
(state) => state.paneForegroundAgentByPaneKey
)
const settings = useAppStore((state) => state.settings)
const worktreeVisibilityDefaultsByHost = useAppStore(
(state) => state.worktreeVisibilityDefaultsByHost
)
const sshTargetLabels = useAppStore((state) => state.sshTargetLabels)
const sshConnectionStates = useAppStore((state) => state.sshConnectionStates)
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
@@ -83,6 +95,9 @@ export function useWorktreeJumpPaletteStoreState({
)
const hideCliCreatedWorkspaces = useAppStore((state) => state.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((state) => state.hideDetachedHeadWorkspaces)
const hideWorkspacesFromOtherDevices = useAppStore(
(state) => state.hideWorkspacesFromOtherDevices
)
const showSleepingWorkspaces = useAppStore((state) => state.showSleepingWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore(
(state) => state.alwaysShowDefaultBranchWorkspace
@@ -102,6 +117,7 @@ export function useWorktreeJumpPaletteStoreState({
return {
visible,
paletteNowMs,
closeModal,
openModal,
openSettingsPage,
@@ -127,6 +143,7 @@ export function useWorktreeJumpPaletteStoreState({
migrationUnsupportedByPtyId,
activeView,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
@@ -146,7 +163,9 @@ export function useWorktreeJumpPaletteStoreState({
groupsByWorktree,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
paneForegroundAgentByPaneKey,
settings,
worktreeVisibilityDefaultsByHost,
sshTargetLabels,
sshConnectionStates,
runtimeEnvironments,
@@ -155,6 +174,7 @@ export function useWorktreeJumpPaletteStoreState({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices,
showSleepingWorkspaces,
alwaysShowDefaultBranchWorkspace,
lastVisitedAtByWorktreeId,
@@ -0,0 +1,202 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { getComposerEligibleRepos, resolveComposerActiveRepoId } from '@/lib/new-workspace-composer-repo'
import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-target'
import { lookupCmdJGitHubUrlWorkItem } from '@/lib/cmd-j-github-url-lookup'
import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup'
import { withResolvedCmdJGitHubPreview, type CmdJTaskSourceUrl, getCmdJTaskUrlCreatePreview } from '@/lib/worktree-palette-task-url-match'
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { LinearIssue } from '../../../shared/linear/issue-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context'
import type { WorktreePaletteRequestGuard } from '@/lib/worktree-palette-create-action'
export type CmdJLinearIssuePreview = {
query: string
issue: LinearIssue | null
loading: boolean
initialRepoId: string | null
sourceContext: TaskSourceContext | null
}
export type CmdJGitHubWorkItemPreview = {
query: string
item: GitHubWorkItem | null
loading: boolean
initialRepoId: string | null
sourceContext: TaskSourceContext | null
}
function getComposerDefaultWorkspaceTarget(state: ReturnType<typeof useAppStore.getState>) {
const eligibleRepos = getComposerEligibleRepos(state.repos)
const activeRepoId = resolveComposerActiveRepoId(state.repos, eligibleRepos, state.activeRepoId)
const resolution = resolveWorkspaceCreationTarget({
eligibleRepos,
projects: state.projects,
projectHostSetups: state.projectHostSetups,
activeRepoId,
focusedHostScope: state.workspaceHostScope
})
return resolution.status === 'ready' ? resolution.target : null
}
export function useWorktreeJumpPaletteTaskUrl({
visible,
createWorktreeName,
taskSourceUrl,
createLookupGuard
}: {
visible: boolean
createWorktreeName: string
taskSourceUrl: CmdJTaskSourceUrl | null
createLookupGuard: WorktreePaletteRequestGuard
}) {
const linearIssueUrlIntent = taskSourceUrl?.provider === 'linear' ? taskSourceUrl.intent : null
const githubUrlLink = taskSourceUrl?.provider === 'github' ? taskSourceUrl.link : null
const parsedTaskUrlCreatePreview = useMemo(
() => (taskSourceUrl ? getCmdJTaskUrlCreatePreview(taskSourceUrl) : null),
[taskSourceUrl]
)
const [linearIssuePreview, setLinearIssuePreview] = useState<CmdJLinearIssuePreview | null>(null)
const [githubWorkItemPreview, setGithubWorkItemPreview] =
useState<CmdJGitHubWorkItemPreview | null>(null)
const linearLookupRef = useRef<{ query: string; promise: Promise<CmdJLinearIssuePreview> } | null>(null)
const githubLookupRef = useRef<{ query: string; promise: Promise<CmdJGitHubWorkItemPreview> } | null>(null)
const linearGenerationRef = useRef(0)
const githubGenerationRef = useRef(0)
useLayoutEffect(() => {
const generation = ++linearGenerationRef.current
linearLookupRef.current = null
if (!visible || !linearIssueUrlIntent) {
setLinearIssuePreview(null)
return
}
const state = useAppStore.getState()
const target = getComposerDefaultWorkspaceTarget(state)
const sourceContext = target
? buildTaskSourceContextFromRepo({
provider: 'linear',
projectId: target.projectId,
repo: target.repo,
projectHostSetupId: target.projectHostSetupId
})
: null
const pending: CmdJLinearIssuePreview = {
query: createWorktreeName,
issue: null,
loading: true,
initialRepoId: target?.repoId ?? null,
sourceContext
}
setLinearIssuePreview(pending)
const promise = lookupLinearIssueUrl({
intent: linearIssueUrlIntent,
knownStatus: state.linearStatus,
sourceContext,
fetchLinearIssue: state.fetchLinearIssue
})
.catch(() => null)
.then((issue): CmdJLinearIssuePreview => ({ ...pending, issue, loading: false }))
linearLookupRef.current = { query: createWorktreeName, promise }
void promise.then((preview) => {
if (linearGenerationRef.current === generation) {
setLinearIssuePreview(preview)
}
})
return () => {
if (linearGenerationRef.current === generation) {
linearGenerationRef.current += 1
}
}
}, [createWorktreeName, linearIssueUrlIntent, visible])
useLayoutEffect(() => {
const generation = ++githubGenerationRef.current
githubLookupRef.current = null
if (!visible || !githubUrlLink) {
setGithubWorkItemPreview(null)
return
}
const state = useAppStore.getState()
const target = getComposerDefaultWorkspaceTarget(state)
const sourceContext = target
? buildTaskSourceContextFromRepo({
provider: 'github',
projectId: target.projectId,
repo: target.repo,
projectHostSetupId: target.projectHostSetupId
})
: null
const pending: CmdJGitHubWorkItemPreview = {
query: createWorktreeName,
item: null,
loading: true,
initialRepoId: target?.repoId ?? null,
sourceContext
}
setGithubWorkItemPreview(pending)
const promise = lookupCmdJGitHubUrlWorkItem({
link: githubUrlLink,
repo: target?.repo ?? null,
sourceContext
})
.catch(() => null)
.then((item): CmdJGitHubWorkItemPreview => ({ ...pending, item, loading: false }))
githubLookupRef.current = { query: createWorktreeName, promise }
void promise.then((preview) => {
if (githubGenerationRef.current === generation) {
setGithubWorkItemPreview(preview)
}
})
return () => {
if (githubGenerationRef.current === generation) {
githubGenerationRef.current += 1
}
}
}, [createWorktreeName, githubUrlLink, visible])
const currentLinearIssuePreview =
linearIssuePreview?.query === createWorktreeName ? linearIssuePreview : null
const currentGitHubWorkItemPreview =
githubWorkItemPreview?.query === createWorktreeName ? githubWorkItemPreview : null
const taskUrlCreatePreview = useMemo(() => {
if (!parsedTaskUrlCreatePreview) {
return null
}
return withResolvedCmdJGitHubPreview(
parsedTaskUrlCreatePreview,
currentGitHubWorkItemPreview?.item?.title ?? null,
currentGitHubWorkItemPreview?.loading === true
)
}, [currentGitHubWorkItemPreview, parsedTaskUrlCreatePreview])
const [linearLoadingFeedbackQuery, setLinearLoadingFeedbackQuery] = useState<string | null>(null)
useEffect(() => {
if (!currentLinearIssuePreview?.loading) {
setLinearLoadingFeedbackQuery(null)
return
}
setLinearLoadingFeedbackQuery(null)
const timer = window.setTimeout(
() => setLinearLoadingFeedbackQuery(currentLinearIssuePreview.query),
200
)
return () => window.clearTimeout(timer)
}, [currentLinearIssuePreview?.loading, currentLinearIssuePreview?.query])
return {
linearIssueUrlIntent,
githubUrlLink,
taskUrlCreatePreview,
currentLinearIssuePreview,
currentGitHubWorkItemPreview,
linearLookupRef,
githubLookupRef,
showLinearLoadingFeedback:
currentLinearIssuePreview?.loading === true &&
linearLoadingFeedbackQuery === currentLinearIssuePreview.query,
createLookupGuard
}
}
export type WorktreeJumpPaletteTaskUrl = ReturnType<typeof useWorktreeJumpPaletteTaskUrl>
@@ -10,8 +10,24 @@ import { sortWorktreesSmart } from '@/components/sidebar/smart-sort'
import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index'
import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees'
import { getWorktreePaletteSearchScope, searchWorktrees } from '@/lib/worktree-palette-search'
import {
getWorktreePaletteSearchScope,
searchWorktreeDocuments
} from '@/lib/worktree-palette-search'
import { buildWorktreePaletteDocuments } from '@/lib/worktree-palette-document'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import {
buildPaletteWorktreeIndex,
resolvePaletteWorktree,
resolvePaletteRepoForWorktree
} from '@/lib/palette-repo-resolution'
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import {
EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
getPairedDeviceIdsByEnvironment,
isWorkspaceFromOtherDevice
} from '@/components/sidebar/workspace-creator-visibility'
import type { Worktree } from '../../../shared/worktree/types'
import { EMPTY_SORTED_WORKTREES } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter'
@@ -19,11 +35,14 @@ import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
type WorktreeJumpPaletteWorktreesInput = WorktreeJumpPaletteStoreState &
Pick<WorktreeJumpPaletteFilter, 'filterPredicate' | 'repoMap' | 'repoByHostIdentity'> &
Pick<WorktreeJumpPaletteLocalState, 'deferredQuery'>
Pick<
WorktreeJumpPaletteFilter,
'filterPredicate' | 'repoMap' | 'repoByHostIdentity' | 'hostOptions' | 'hostFilterActive'
> &
Pick<WorktreeJumpPaletteLocalState, 'paletteSearchQuery'>
export function useWorktreeJumpPaletteWorktrees({
deferredQuery,
paletteSearchQuery,
repos,
worktreesByRepo,
agentStatusByPaneKey,
@@ -34,11 +53,15 @@ export function useWorktreeJumpPaletteWorktrees({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices,
showSleepingWorkspaces,
alwaysShowDefaultBranchWorkspace,
ptyIdsByTabId,
browserTabsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
runtimeEnvironments,
runtimeStatusByEnvironmentId,
lastVisitedAtByWorktreeId,
paletteStatusInputsActive,
repoMap,
@@ -46,13 +69,15 @@ export function useWorktreeJumpPaletteWorktrees({
migrationUnsupportedByPtyId,
terminalLayoutsByTabId,
repoByHostIdentity,
hostOptions,
hostFilterActive,
prCache,
hostedReviewCache,
settings,
issueCache,
workspacePortScan
}: WorktreeJumpPaletteWorktreesInput) {
const hasQuery = deferredQuery.trim().length > 0
const hasQuery = paletteSearchQuery.length > 0
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
const worktreeIdsWithLiveAgent = useMemo(
() =>
@@ -64,6 +89,13 @@ export function useWorktreeJumpPaletteWorktrees({
),
[agentStatusByPaneKey, tabsByWorktree]
)
const pairedDeviceIdsByEnvironment = useMemo(
() =>
hideWorkspacesFromOtherDevices
? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId)
: EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
[hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId]
)
const emptyQueryVisibleWorktrees = useMemo(
() =>
allWorktrees.filter((worktree) => {
@@ -85,6 +117,12 @@ export function useWorktreeJumpPaletteWorktrees({
if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) {
return false
}
if (
hideWorkspacesFromOtherDevices &&
isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment)
) {
return false
}
if (
!showSleepingWorkspaces &&
!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) &&
@@ -109,6 +147,8 @@ export function useWorktreeJumpPaletteWorktrees({
hideCliCreatedWorkspaces,
hideDefaultBranchWorkspace,
hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices,
pairedDeviceIdsByEnvironment,
ptyIdsByTabId,
showSleepingWorkspaces,
tabsByWorktree,
@@ -120,9 +160,15 @@ export function useWorktreeJumpPaletteWorktrees({
orderEmptyQueryWorktrees({
visibleWorktrees: emptyQueryVisibleWorktrees,
activeWorktreeId,
activeWorkspaceExecutionHostId,
lastVisitedAtByWorktreeId
}),
[emptyQueryVisibleWorktrees, activeWorktreeId, lastVisitedAtByWorktreeId]
[
emptyQueryVisibleWorktrees,
activeWorktreeId,
activeWorkspaceExecutionHostId,
lastVisitedAtByWorktreeId
]
)
const searchScopeWorktrees = useMemo(() => {
const scope = getWorktreePaletteSearchScope({
@@ -168,17 +214,45 @@ export function useWorktreeJumpPaletteWorktrees({
: searchScopeWorktrees,
[hasQuery, browserSortedWorktrees, searchScopeWorktrees]
)
const paletteWorktreeIndex = useMemo(
() => buildPaletteWorktreeIndex(browserSortedWorktrees),
[browserSortedWorktrees]
)
const resolveWorktree = useMemo(
() =>
(worktreeId: string, hostId: Worktree['hostId'] | undefined): Worktree | undefined =>
resolvePaletteWorktree(paletteWorktreeIndex, worktreeId, hostId),
[paletteWorktreeIndex]
)
// Keep a host-qualified map for consumers that only have an identity key.
const worktreeMap = useMemo(() => {
const map = new Map<string, Worktree>()
for (const worktree of browserSortedWorktrees) {
map.set(worktree.id, worktree)
map.set(getWorktreeHostIdentity(worktree), worktree)
if (!map.has(worktree.id)) {
map.set(worktree.id, worktree)
}
}
return map
}, [browserSortedWorktrees])
const worktreeOrder = useMemo(
() => new Map(browserSortedWorktrees.map((worktree, index) => [worktree.id, index])),
() =>
new Map(
browserSortedWorktrees.map((worktree, index) => [getWorktreeHostIdentity(worktree), index])
),
[browserSortedWorktrees]
)
const hostLabelByWorktreeId = useMemo(() => {
const labels = new Map<string, string>()
for (const worktree of allWorktrees) {
const repo = resolvePaletteRepoForWorktree(worktree, repoMap, repoByHostIdentity)
const badge = getPaletteHostBadge(repo, hostOptions, hostFilterActive)
if (badge) {
labels.set(getWorktreeHostIdentity(worktree), badge.label)
}
}
return labels
}, [allWorktrees, hostFilterActive, hostOptions, repoByHostIdentity, repoMap])
const checksReviewByWorktree = useMemo(
() =>
buildWorktreeChecksReviewIndex({
@@ -190,24 +264,48 @@ export function useWorktreeJumpPaletteWorktrees({
}),
[allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings]
)
const worktreeDocuments = useMemo(
() =>
buildWorktreePaletteDocuments(
allWorktrees.filter((worktree) => !worktree.isArchived),
{
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
prCache,
issueCache,
workspacePortsByWorktreeId: getWorkspacePortsByWorktreeId(workspacePortScan),
checksReviewByWorktree,
hostLabelByWorktreeId
}
),
[
allWorktrees,
checksReviewByWorktree,
issueCache,
prCache,
repoByHostIdentity,
repoMap,
workspacePortScan,
hostLabelByWorktreeId
]
)
const worktreeMatches = useMemo(
() =>
searchWorktrees(sortedWorktrees, deferredQuery.trim(), repoMap, {
searchWorktreeDocuments({
worktrees: sortedWorktrees,
query: paletteSearchQuery,
documents: worktreeDocuments,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
prCache,
issueCache,
workspacePortsByWorktreeId: getWorkspacePortsByWorktreeId(workspacePortScan),
checksReviewByWorktree
}),
[
sortedWorktrees,
deferredQuery,
repoMap,
checksReviewByWorktree,
paletteSearchQuery,
repoByHostIdentity,
prCache,
issueCache,
workspacePortScan,
checksReviewByWorktree
repoMap,
sortedWorktrees,
worktreeDocuments
]
)
return {
@@ -218,6 +316,8 @@ export function useWorktreeJumpPaletteWorktrees({
searchScopeWorktrees,
browserSortedWorktrees,
worktreeMap,
resolveWorktree,
paletteWorktreeIndex,
worktreeOrder,
worktreeMatches
}
@@ -13,18 +13,26 @@ import {
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
export function WorktreeJumpPaletteSimulatorRow({
entry,
renderKey,
controller
}: {
entry: SimulatorPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const result = entry.result
const simulatorWorktree = controller.worktreeMap.get(result.worktreeId)
const simulatorWorktree = controller.resolveWorktree(result.worktreeId, result.executionHostId)
const simulatorRepo = simulatorWorktree
? controller.repoMap.get(simulatorWorktree.repoId)
? resolvePaletteRepoForWorktree(
simulatorWorktree,
controller.repoMap,
controller.repoByHostIdentity
)
: undefined
const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName
const simulatorHostBadge = getPaletteHostBadge(
@@ -32,11 +40,14 @@ export function WorktreeJumpPaletteSimulatorRow({
controller.hostOptions,
controller.hostFilterActive
)
const simulatorSessionAge = formatPaletteSessionAge(
result.lastActiveAt ?? null,
controller.paletteNowMs
)
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => controller.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]',
@@ -56,6 +67,7 @@ export function WorktreeJumpPaletteSimulatorRow({
secondaryRanges={result.secondaryRanges}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={simulatorSessionAge}
leadingBadges={
<>
{result.isCurrentTab && (
@@ -86,7 +98,7 @@ export function WorktreeJumpPaletteSimulatorRow({
</span>
)}
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexById.get(entry.id)}
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
/>
</div>
@@ -98,25 +110,36 @@ export function WorktreeJumpPaletteSimulatorRow({
export function WorktreeJumpPaletteBrowserRow({
entry,
renderKey,
controller
}: {
entry: BrowserPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const result = entry.result
const browserWorktree = controller.worktreeMap.get(result.worktreeId)
const browserRepo = browserWorktree ? controller.repoMap.get(browserWorktree.repoId) : undefined
const browserWorktree = controller.resolveWorktree(result.worktreeId, result.executionHostId)
const browserRepo = browserWorktree
? resolvePaletteRepoForWorktree(
browserWorktree,
controller.repoMap,
controller.repoByHostIdentity
)
: undefined
const browserRepoName = browserRepo?.displayName ?? result.repoName
const browserHostBadge = getPaletteHostBadge(
browserRepo,
controller.hostOptions,
controller.hostFilterActive
)
const browserSessionAge = formatPaletteSessionAge(
result.lastActiveAt ?? null,
controller.paletteNowMs
)
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => controller.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]',
@@ -136,6 +159,7 @@ export function WorktreeJumpPaletteBrowserRow({
secondaryRanges={result.secondaryRanges}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={browserSessionAge}
leadingBadges={
<>
{result.isCurrentPage && (
@@ -166,7 +190,7 @@ export function WorktreeJumpPaletteBrowserRow({
</span>
)}
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexById.get(entry.id)}
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
/>
</div>
@@ -1,8 +1,8 @@
import type React from 'react'
import { Plus } from 'lucide-react'
import { flushSync } from 'react-dom'
import { CommandItem } from '@/components/ui/command'
import { CREATE_WORKTREE_ITEM_ID } from '@/lib/worktree-palette-create-action'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { PaletteCreateWorktreeRow } from '@/components/cmd-j/PaletteCreateWorktreeRow'
import type { PaletteListEntry } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import { WorktreeJumpPaletteWorktreeRow } from './worktree-jump-palette-worktree-row'
@@ -15,12 +15,15 @@ import {
WorktreeJumpPaletteBrowserRow,
WorktreeJumpPaletteSimulatorRow
} from './worktree-jump-palette-browser-simulator-rows'
import { translate } from '@/i18n/i18n'
export function WorktreeJumpPaletteEntry({
entry,
renderKey,
controller
}: {
entry: PaletteListEntry
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
if (entry.type === 'section-header') {
@@ -31,48 +34,63 @@ export function WorktreeJumpPaletteEntry({
)
}
if (entry.type === 'hint') {
return (
<div 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
value={CREATE_WORKTREE_ITEM_ID}
onSelect={controller.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"
value={renderKey}
onSelect={() => {
const previousIndex = controller.selectionItemIds.indexOf(renderKey)
flushSync(() => entry.onSeeMore?.())
const expandedItemId = Array.from(
controller.listRef.current?.querySelectorAll<HTMLElement>('[cmdk-item]') ?? []
)[previousIndex]?.getAttribute('data-value')
if (expandedItemId) {
controller.setSelectedItemId(expandedItemId)
}
controller.inputRef.current?.focus()
}}
className={cn(
'group mx-0.5 mt-1 min-h-0 gap-2 py-1.5 text-[12px] text-muted-foreground',
'data-[selected=true]:bg-accent/60'
)}
>
<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: controller.createWorktreeName }
)}
</div>
</div>
<span className="truncate">{entry.label}</span>
{entry.onSeeMore ? (
<span className="h-6 shrink-0 rounded-md border border-input bg-background px-2 text-xs font-medium leading-6 text-foreground">
{translate('worktreeJumpPalette.seeMore', 'See more')}
</span>
) : null}
</CommandItem>
)
}
if (entry.type === 'create-worktree') {
const linearPreview = controller.currentLinearIssuePreview
return (
<PaletteCreateWorktreeRow
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"
createWorktreeName={controller.createWorktreeName}
linearIdentifier={controller.linearIssueUrlIntent?.identifier ?? null}
linearIssue={linearPreview?.issue ?? null}
linearPending={controller.linearIssueUrlIntent !== null && linearPreview?.loading !== false}
showLinearLoadingFeedback={controller.showLinearLoadingFeedback}
taskUrlPreview={controller.taskUrlCreatePreview}
onSelect={controller.handleCreateWorktree}
/>
)
}
if (entry.type === 'worktree') {
return <WorktreeJumpPaletteWorktreeRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteWorktreeRow entry={entry} renderKey={renderKey} controller={controller} />
}
if (entry.type === 'project-target') {
return <WorktreeJumpPaletteProjectRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteProjectRow entry={entry} renderKey={renderKey} controller={controller} />
}
if (entry.type === 'settings' || entry.type === 'quick-action') {
return <WorktreeJumpPaletteActionRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteActionRow entry={entry} renderKey={renderKey} controller={controller} />
}
if (entry.type === 'workspace-tab') {
return <WorktreeJumpPaletteWorkspaceTabRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteWorkspaceTabRow entry={entry} renderKey={renderKey} controller={controller} />
}
if (entry.type === 'simulator-tab') {
return <WorktreeJumpPaletteSimulatorRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteSimulatorRow entry={entry} renderKey={renderKey} controller={controller} />
}
return <WorktreeJumpPaletteBrowserRow entry={entry} controller={controller} />
return <WorktreeJumpPaletteBrowserRow entry={entry} renderKey={renderKey} controller={controller} />
}
@@ -2,8 +2,15 @@ import type { BrowserPaletteSearchResult } from '@/lib/browser-palette-search'
import type { PaletteSearchResult } from '@/lib/worktree-palette-search'
import type { SimulatorPaletteSearchResult } from '@/lib/simulator-palette-search'
import type { WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search'
import type { CmdJActionResult, CmdJSettingsResult } from '@/components/cmd-j/palette-results'
import type { CmdJProjectSearchResult } from '@/components/cmd-j/palette-project-results'
import type {
CmdJActionResult,
CmdJRankedMiddleResult,
CmdJSettingsResult
} from '@/components/cmd-j/palette-results'
import type {
CmdJProjectSearchResult,
CmdJRankedProjectSearchResult
} from '@/components/cmd-j/palette-project-results'
import type { RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows'
import type { Worktree } from '../../../shared/worktree/types'
import { CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions'
@@ -37,27 +44,28 @@ export type WorkspaceTabPaletteItem = {
export type SettingsPaletteItem = {
id: string
type: 'settings'
result: CmdJSettingsResult
result: CmdJSettingsResult & Pick<CmdJRankedMiddleResult, 'qualityClass'>
}
export type QuickActionPaletteItem = {
id: string
type: 'quick-action'
result: CmdJActionResult
result: CmdJActionResult & Pick<CmdJRankedMiddleResult, 'qualityClass'>
}
export type ProjectTargetPaletteItem = {
id: string
type: 'project-target'
result: CmdJProjectSearchResult
result: CmdJProjectSearchResult & Pick<CmdJRankedProjectSearchResult, 'qualityClass'>
}
export type SectionHeader = {
export type SectionHeader = { id: string; type: 'section-header'; label: string }
export type HintRow = {
id: string
type: 'section-header'
type: 'hint'
label: string
onSeeMore?: () => void
}
export type HintRow = { id: string; type: 'hint'; label: string }
export type CreateWorktreePaletteItem = {
id: typeof CREATE_WORKTREE_ITEM_ID
type: 'create-worktree'
@@ -77,6 +85,8 @@ export type OpenTabPaletteItem = BrowserPaletteItem | SimulatorPaletteItem | Wor
export type OpenTabRecentRow = {
item: OpenTabPaletteItem
/** Stable per-occurrence key; persisted tab ids can collide across hosts/snapshots. */
occurrenceId: string
worktree: Worktree
row: RecentWorkspaceTabRow
}
@@ -1,8 +1,12 @@
import { useLayoutEffect, useRef, useState } from 'react'
import type React from 'react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
import type { PaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import type { MatchRange, PaletteSearchResult } from '@/lib/worktree-palette-search'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { Worktree } from '../../../shared/worktree/types'
import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name'
export function PaletteRowShortcutBadge({
index,
@@ -67,6 +71,7 @@ export function PaletteOpenTabPrimaryLine({
secondaryRanges,
worktreeName,
worktreeRanges,
sessionAge,
leadingBadges
}: {
title: string
@@ -75,6 +80,7 @@ export function PaletteOpenTabPrimaryLine({
secondaryRanges: readonly MatchRange[]
worktreeName: string
worktreeRanges: readonly MatchRange[]
sessionAge?: string
leadingBadges?: React.ReactNode
}): React.JSX.Element {
const showSecondary = secondaryText.trim().length > 0
@@ -82,9 +88,24 @@ export function PaletteOpenTabPrimaryLine({
return (
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span className="min-w-0 max-w-[42%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<span
data-slot="palette-open-tab-title"
className="min-w-0 flex-1 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
>
<HighlightedText text={title} matchRanges={titleRanges} />
</span>
{sessionAge ? (
<span
aria-label={translate(
'auto.components.WorktreeJumpPalette.lastActiveTime',
'Last active {{value0}} ago',
{ value0: sessionAge }
)}
className="shrink-0 text-[11px] font-medium tabular-nums text-muted-foreground/70"
>
{sessionAge}
</span>
) : null}
{leadingBadges}
{showSecondary ? (
<>
@@ -97,7 +118,10 @@ export function PaletteOpenTabPrimaryLine({
{showWorktree ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<span
data-slot="palette-open-tab-worktree"
className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92"
>
<HighlightedText text={worktreeName} matchRanges={worktreeRanges} />
</span>
</>
@@ -106,6 +130,69 @@ export function PaletteOpenTabPrimaryLine({
)
}
function resolveOpenTabWorktreeRailTooltip({
isBranch,
truncated,
name
}: {
isBranch: boolean
truncated: boolean
name: string
}): string {
if (truncated) return name
return isBranch
? translate('auto.components.WorktreeJumpPalette.paletteOpenTabBranch', 'Branch name')
: translate('auto.components.WorktreeJumpPalette.paletteOpenTabWorkspace', 'Workspace name')
}
export function PaletteOpenTabWorktreeRailLabel({
name,
matchRanges,
worktree,
className,
slot = 'palette-open-tab-worktree'
}: {
name: string
matchRanges: readonly MatchRange[]
worktree?: Pick<Worktree, 'branch'> | null
className?: string
slot?: string
}): React.JSX.Element | null {
const [truncated, setTruncated] = useState(false)
const labelRef = useRef<HTMLSpanElement | null>(null)
useLayoutEffect(() => {
const node = labelRef.current
if (!node) {
setTruncated(false)
return
}
const updateTruncated = (): void => {
const next = node.scrollWidth > node.clientWidth
setTruncated((current) => (current === next ? current : next))
}
updateTruncated()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(updateTruncated)
observer.observe(node)
return () => observer.disconnect()
}, [name])
if (name.trim().length === 0) return null
const isBranch = worktree != null && name === resolveWorktreeBranchLabel(worktree)
const tooltip = resolveOpenTabWorktreeRailTooltip({ isBranch, truncated, name })
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={labelRef} data-slot={slot} tabIndex={-1} className={className}>
<HighlightedText text={name} matchRanges={matchRanges} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 break-all">
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export function PaletteState({
title,
subtitle
@@ -15,9 +15,11 @@ import { PaletteHostBadgeChip } from './worktree-jump-palette-primitives'
export function WorktreeJumpPaletteProjectRow({
entry,
renderKey,
controller
}: {
entry: ProjectTargetPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const result = entry.result
@@ -31,8 +33,7 @@ export function WorktreeJumpPaletteProjectRow({
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => controller.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]',
@@ -71,9 +72,11 @@ export function WorktreeJumpPaletteProjectRow({
export function WorktreeJumpPaletteActionRow({
entry,
renderKey,
controller
}: {
entry: SettingsPaletteItem | QuickActionPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const result = entry.result
@@ -85,8 +88,7 @@ export function WorktreeJumpPaletteActionRow({
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => controller.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]',
@@ -45,5 +45,5 @@ export function shouldIncludeOpenTabInRecentSection({
unreadAgentCompletionPanes
})
})
return badge != null && badge !== 'done'
return badge != null && badge !== 'done' && badge !== 'interrupted'
}
@@ -1,5 +1,6 @@
import type React from 'react'
import { CommandDialog, CommandEmpty, CommandInput, CommandList } from '@/components/ui/command'
import { TooltipProvider } from '@/components/ui/tooltip'
import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu'
import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips'
import { PaletteLiveStatusProvider } from '@/components/cmd-j/palette-live-status'
@@ -39,7 +40,13 @@ export function WorktreeJumpPaletteSurface({
loop: true,
value: controller.commandSelectedItemId,
onValueChange: controller.handleCommandSelectionChange,
className: 'bg-transparent'
className: 'bg-transparent',
onKeyDownCapture: (event: React.KeyboardEvent) => {
if (controller.selectionMovedByUserRef &&
['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp'].includes(event.key)) {
controller.selectionMovedByUserRef.current = true
}
}
}}
>
<CommandInput
@@ -88,6 +95,9 @@ export function WorktreeJumpPaletteSurface({
/>
<CommandList
ref={controller.listRef}
onPointerDownCapture={() => {
controller.selectionMovedByUserRef.current = true
}}
className="max-h-[min(600px,calc(100vh-14rem))] px-2.5 pb-2.5 pt-2"
>
{controller.isLoading &&
@@ -109,8 +119,13 @@ export function WorktreeJumpPaletteSurface({
</CommandEmpty>
) : (
<>
{controller.listEntries.map((entry) => (
<WorktreeJumpPaletteEntry key={entry.id} entry={entry} controller={controller} />
{controller.listEntries.map((entry, index) => (
<WorktreeJumpPaletteEntry
key={controller.listEntryRenderKeys[index] ?? entry.id}
entry={entry}
renderKey={controller.listEntryRenderKeys[index] ?? entry.id}
controller={controller}
/>
))}
</>
)}
@@ -159,8 +174,10 @@ export function WorktreeJumpPaletteSurface({
)
return (
<PaletteLiveStatusProvider active={controller.paletteStatusInputsActive}>
{paletteDialog}
</PaletteLiveStatusProvider>
<TooltipProvider delayDuration={400}>
<PaletteLiveStatusProvider active={controller.paletteStatusInputsActive}>
{paletteDialog}
</PaletteLiveStatusProvider>
</TooltipProvider>
)
}
@@ -1,5 +1,6 @@
import type React from 'react'
import { FileText, SquareTerminal } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { CommandItem } from '@/components/ui/command'
import { PaletteRecentTabStatusDot } from '@/components/cmd-j/palette-live-status'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
@@ -14,18 +15,29 @@ import {
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
export function WorktreeJumpPaletteWorkspaceTabRow({
entry,
renderKey,
controller
}: {
entry: WorkspaceTabPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const result = entry.result
const workspaceTabWorktree = controller.worktreeMap.get(result.worktreeId)
const workspaceTabWorktree = controller.resolveWorktree(
result.worktreeId,
result.executionHostId
)
const workspaceTabRepo = workspaceTabWorktree
? controller.repoMap.get(workspaceTabWorktree.repoId)
? resolvePaletteRepoForWorktree(
workspaceTabWorktree,
controller.repoMap,
controller.repoByHostIdentity
)
: undefined
const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName
const workspaceTabHostBadge = getPaletteHostBadge(
@@ -33,13 +45,22 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
controller.hostOptions,
controller.hostFilterActive
)
const WorkspaceTabIcon = result.contentType === 'terminal' ? SquareTerminal : FileText
const recentRow = controller.hasQuery ? null : (controller.recentTabRowById.get(entry.id) ?? null)
const recentRow = controller.recentTabRowByItem.get(entry) ?? null
const fallback =
result.contentType === 'terminal' && result.occupantAgent ? (
<span className="inline-flex" data-agent-icon={result.occupantAgent} aria-hidden="true">
<AgentIcon agent={result.occupantAgent} size={14} />
</span>
) : result.contentType === 'terminal' ? (
<SquareTerminal className="size-3.5" aria-hidden="true" />
) : (
<FileText className="size-3.5" aria-hidden="true" />
)
const sessionAge = formatPaletteSessionAge(result.lastActiveAt ?? null, controller.paletteNowMs)
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => controller.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]',
@@ -49,7 +70,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
<div className="flex h-5 w-4 shrink-0 items-center justify-center self-start text-muted-foreground/85">
<PaletteRecentTabStatusDot
row={recentRow}
fallback={<WorkspaceTabIcon className="size-3.5" aria-hidden="true" />}
fallback={fallback}
/>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
@@ -62,6 +83,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
secondaryRanges={result.secondaryRanges}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={sessionAge}
leadingBadges={
<>
{result.isCurrentTab && (
@@ -92,7 +114,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
</span>
)}
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexById.get(entry.id)}
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
/>
</div>
@@ -11,6 +11,8 @@ import {
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import type { WorktreePaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import {
@@ -21,9 +23,11 @@ import {
export function WorktreeJumpPaletteWorktreeRow({
entry,
renderKey,
controller
}: {
entry: WorktreePaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const {
@@ -35,11 +39,20 @@ export function WorktreeJumpPaletteWorktreeRow({
handleSelectItem
} = controller
const worktree = entry.worktree
const repo = repoMap.get(worktree.repoId)
const repo = resolvePaletteRepoForWorktree(
worktree,
repoMap,
controller.repoByHostIdentity
)
const repoName = repo?.displayName ?? ''
const branch = resolveWorktreeBranchLabel(worktree)
const worktreeLabel = resolveWorktreeDisplayName(worktree)
const isCurrentWorktree = activeWorktreeId === worktree.id
const isCurrentWorktree = isPaletteCurrentWorktree(
worktree,
activeWorktreeId,
controller.activeWorkspaceExecutionHostId
)
const sessionAge = formatPaletteSessionAge(worktree.lastActivityAt, controller.paletteNowMs)
const sshConnectionId =
repo?.connectionId && !isRuntimeOwnedSshTargetId(repo.connectionId) ? repo.connectionId : null
const sshStatus = sshConnectionId
@@ -50,8 +63,7 @@ export function WorktreeJumpPaletteWorktreeRow({
return (
<CommandItem
key={entry.id}
value={entry.id}
value={renderKey}
onSelect={() => handleSelectItem(entry)}
data-current={isCurrentWorktree ? 'true' : undefined}
className={cn(
@@ -88,6 +100,18 @@ export function WorktreeJumpPaletteWorktreeRow({
<span className="truncate text-[14px] font-semibold text-foreground">
<HighlightedText text={worktreeLabel} matchRanges={entry.match.displayNameRanges} />
</span>
{sessionAge ? (
<span
aria-label={translate(
'auto.components.WorktreeJumpPalette.lastActiveTime',
'Last active {{value0}} ago',
{ value0: sessionAge }
)}
className="shrink-0 text-[11px] font-medium tabular-nums text-muted-foreground/70"
>
{sessionAge}
</span>
) : null}
{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')}
@@ -98,10 +122,14 @@ export function WorktreeJumpPaletteWorktreeRow({
{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">
<HighlightedText text={branch} matchRanges={entry.match.branchRanges} />
</span>
{branch.trim().length > 0 ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={branch} matchRanges={entry.match.branchRanges} />
</span>
</>
) : null}
</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">