diff --git a/src/renderer/src/components/use-worktree-jump-palette-controller.ts b/src/renderer/src/components/use-worktree-jump-palette-controller.ts index 549f4fb1799..0c167dfb5f8 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-controller.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-controller.ts @@ -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, diff --git a/src/renderer/src/components/use-worktree-jump-palette-create-action.ts b/src/renderer/src/components/use-worktree-jump-palette-create-action.ts index 0f3d01c853f..31b07bf64bd 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-create-action.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-create-action.ts @@ -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 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): 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 => { + 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 => { + 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 = { 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('[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('[role="dialog"]') ?? null), + [setDialogElement] + ) + const handleOpenAutoFocus = useCallback((_event: Event) => {}, []) return { handleCreateWorktree, handleCloseAutoFocus, @@ -222,4 +321,6 @@ export function useWorktreeJumpPaletteCreateAction({ } } -export type WorktreeJumpPaletteCreateAction = ReturnType +export type WorktreeJumpPaletteCreateAction = ReturnType< + typeof useWorktreeJumpPaletteCreateAction +> diff --git a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts index c9d97aa2cb8..3fbb18483ff 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts @@ -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 & - Pick & - Pick + Pick & + Pick export function useWorktreeJumpPaletteListEntries({ hasQuery, openTabsLeadSections, paletteSections, showCreateAction, - worktreeItems, - autoSelectedItemIdRef + autoSelectedItemIdRef, + taskSourceUrl, + handleExpandSection, + middleLeadsSections }: WorktreeJumpPaletteListEntriesInput) { const listEntries = useMemo(() => { 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( () => 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 diff --git a/src/renderer/src/components/use-worktree-jump-palette-local-state.ts b/src/renderer/src/components/use-worktree-jump-palette-local-state.ts index adaaaa1efc2..f854a53b511 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-local-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-local-state.ts @@ -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(null) + // Create is armed by an explicit keyboard/pointer move, except for task URLs. + const selectionMovedByUserRef = useRef(false) const digitShortcutItemsRef = useRef([]) const [rawFilter, setRawFilter] = useState(EMPTY_PALETTE_FILTER) const [dialogElement, setDialogElement] = useState(null) @@ -31,15 +50,31 @@ export function useWorktreeJumpPaletteLocalState({ const fallbackFocusOuterFrameRef = useRef(null) const fallbackFocusInnerFrameRef = useRef(null) const preserveCreateLookupOnCloseRef = useRef(false) + const [expandedSectionCaps, setExpandedSectionCaps] = useState>({}) + + // 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 } } diff --git a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts index c960bfe39e7..4175906bac5 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts @@ -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 & + Pick & Pick 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(() => { 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() - 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(() => { 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( () => browserMatches.map((result) => ({ @@ -242,28 +258,28 @@ export function useWorktreeJumpPaletteOpenTabs({ ) const openTabItems = useMemo(() => { 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, diff --git a/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts b/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts index f663c116a84..977f9cfbdba 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts @@ -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({ diff --git a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts index 84eab53c524..0a9ca455e24 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts @@ -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' }) diff --git a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts index 8163a6f7f6c..62e82f0328d 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts @@ -23,10 +23,43 @@ import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-w type WorktreeJumpPaletteRecentTabsInput = WorktreeJumpPaletteStoreState & WorktreeJumpPaletteOpenTabs & - Pick & + Pick & Pick & Pick +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() + 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() for (const tabs of Object.values(tabsByWorktree)) { @@ -77,17 +119,21 @@ export function useWorktreeJumpPaletteRecentTabs({ ) const openTabRecentRows = useMemo(() => { 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(() => { @@ -190,11 +236,13 @@ export function useWorktreeJumpPaletteRecentTabs({ visible ]) const recentTabItems = useMemo(() => { - 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 diff --git a/src/renderer/src/components/use-worktree-jump-palette-sections.ts b/src/renderer/src/components/use-worktree-jump-palette-sections.ts index b0cf0e772d8..a0e34cbcff3 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-sections.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-sections.ts @@ -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 & Pick & - Pick + 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({ 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 } } diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts index d32d831df43..8906add43e2 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts @@ -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 || diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index a5e666e3d40..2e052c2184a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -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, diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-url.ts b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts new file mode 100644 index 00000000000..ae78d1bfee7 --- /dev/null +++ b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts @@ -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) { + 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(null) + const [githubWorkItemPreview, setGithubWorkItemPreview] = + useState(null) + const linearLookupRef = useRef<{ query: string; promise: Promise } | null>(null) + const githubLookupRef = useRef<{ query: string; promise: Promise } | 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(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 diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index 78642183fb0..3991a27ce38 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -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 & - Pick + Pick< + WorktreeJumpPaletteFilter, + 'filterPredicate' | 'repoMap' | 'repoByHostIdentity' | 'hostOptions' | 'hostFilterActive' + > & + Pick 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() 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() + 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 } diff --git a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx index 101c04ba795..e19c735886c 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx @@ -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 ( 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({ )} @@ -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 ( 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({ )} diff --git a/src/renderer/src/components/worktree-jump-palette-entry.tsx b/src/renderer/src/components/worktree-jump-palette-entry.tsx index d33bf88b2bc..910a09e1b9c 100644 --- a/src/renderer/src/components/worktree-jump-palette-entry.tsx +++ b/src/renderer/src/components/worktree-jump-palette-entry.tsx @@ -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 ( -
- {entry.label} -
- ) - } - if (entry.type === 'create-worktree') { return ( { + const previousIndex = controller.selectionItemIds.indexOf(renderKey) + flushSync(() => entry.onSeeMore?.()) + const expandedItemId = Array.from( + controller.listRef.current?.querySelectorAll('[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' + )} > -
-
-
-
- {translate( - 'auto.components.WorktreeJumpPalette.95be6587d3', - 'Create worktree "{{value0}}"', - { value0: controller.createWorktreeName } - )} -
-
+ {entry.label} + {entry.onSeeMore ? ( + + {translate('worktreeJumpPalette.seeMore', 'See more')} + + ) : null}
) } + if (entry.type === 'create-worktree') { + const linearPreview = controller.currentLinearIssuePreview + return ( + + ) + } if (entry.type === 'worktree') { - return + return } if (entry.type === 'project-target') { - return + return } if (entry.type === 'settings' || entry.type === 'quick-action') { - return + return } if (entry.type === 'workspace-tab') { - return + return } if (entry.type === 'simulator-tab') { - return + return } - return + return } diff --git a/src/renderer/src/components/worktree-jump-palette-model.ts b/src/renderer/src/components/worktree-jump-palette-model.ts index fd060f3f141..5721c2aa76a 100644 --- a/src/renderer/src/components/worktree-jump-palette-model.ts +++ b/src/renderer/src/components/worktree-jump-palette-model.ts @@ -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 } export type QuickActionPaletteItem = { id: string type: 'quick-action' - result: CmdJActionResult + result: CmdJActionResult & Pick } export type ProjectTargetPaletteItem = { id: string type: 'project-target' - result: CmdJProjectSearchResult + result: CmdJProjectSearchResult & Pick } -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 } diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.tsx index 1c5f8c497ee..739c0aad577 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.tsx @@ -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 (
- + + {sessionAge ? ( + + {sessionAge} + + ) : null} {leadingBadges} {showSecondary ? ( <> @@ -97,7 +118,10 @@ export function PaletteOpenTabPrimaryLine({ {showWorktree ? ( <> · - + @@ -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 | null + className?: string + slot?: string +}): React.JSX.Element | null { + const [truncated, setTruncated] = useState(false) + const labelRef = useRef(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} + + + ) +} + export function PaletteState({ title, subtitle diff --git a/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx b/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx index 16de94c1e3b..3b8a8187a84 100644 --- a/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx +++ b/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx @@ -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 ( 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 ( 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]', diff --git a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts index f5a1133799a..78e324e9084 100644 --- a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts +++ b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts @@ -45,5 +45,5 @@ export function shouldIncludeOpenTabInRecentSection({ unreadAgentCompletionPanes }) }) - return badge != null && badge !== 'done' + return badge != null && badge !== 'done' && badge !== 'interrupted' } diff --git a/src/renderer/src/components/worktree-jump-palette-surface.tsx b/src/renderer/src/components/worktree-jump-palette-surface.tsx index 56d8d3bf3bd..4b4974069c7 100644 --- a/src/renderer/src/components/worktree-jump-palette-surface.tsx +++ b/src/renderer/src/components/worktree-jump-palette-surface.tsx @@ -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 + } + } }} > { + 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({ ) : ( <> - {controller.listEntries.map((entry) => ( - + {controller.listEntries.map((entry, index) => ( + ))} )} @@ -159,8 +174,10 @@ export function WorktreeJumpPaletteSurface({ ) return ( - - {paletteDialog} - + + + {paletteDialog} + + ) } diff --git a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx index 408257a7f39..bdd61d14cab 100644 --- a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx +++ b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx @@ -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 ? ( + + ) : result.contentType === 'terminal' ? ( +
{entry.match.supportingText && (