Optimize worktree parent picker: conditional mount, virtualization, lazy status loading (#12225)

* Optimize worktree parent picker: conditional mount, virtualization, lazy

- Mount WorktreeParentPickerPopover only when open to avoid hundreds of
  unmounted instances subscribing to lineage and worktree store updates.
- Virtualize the candidate list and resolve activity statuses only for
  visible rows, eliminating redundant status subscriptions.
- Extract filtering, placement calculations, and row rendering into
  separate modules for testability and clarity.

* Memoize worktree parent picker search handler

Wrap search state update in useCallback to stabilize the handler
across re-renders. Reduces unnecessary effect runs and enables better
memoization of child components.

* Optimize worktree parent picker: defer unmount, memoize IDs

- Defer unmount until exit animation completes (200ms) to prevent premature teardown
- Memoize visibleWorktreeIds to prevent status hook from rebuilding its selector on every render
This commit is contained in:
Jinjing 2026-08-03 15:57:05 -07:00 committed by GitHub
parent 25213ec04d
commit ea68d97c28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 625 additions and 104 deletions

View File

@ -84,6 +84,9 @@ const WORKTREE_NATIVE_CONTEXT_MENU_ATTR = 'data-worktree-native-context-menu'
const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500
const DELETE_POSITION_RESTORE_MAX_FRAMES = 180
const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6
// Why: the picker is unmounted on close, which would cut PopoverContent's
// data-[state=closed] exit animation short; hold the subtree for its duration.
const PARENT_PICKER_EXIT_ANIMATION_MS = 200
// Why: stable empty sentinels let closed menu wrappers subscribe to a referentially
// stable value instead of the high-churn maps that delete teardown replaces. The
@ -346,11 +349,13 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
childWorktreeId: string
anchorElement: HTMLElement
} | null>(null)
const [parentPickerOpen, setParentPickerOpen] = useState(false)
const pendingParentPickerRef = useRef<{
childWorktreeId: string
anchorElement: HTMLElement
} | null>(null)
const parentPickerFallbackTimerRef = useRef<number | null>(null)
const parentPickerUnmountTimerRef = useRef<number | null>(null)
const isDeleting = deleteState?.isDeleting ?? false
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
@ -506,6 +511,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
if (parentPickerFallbackTimerRef.current != null) {
window.clearTimeout(parentPickerFallbackTimerRef.current)
}
if (parentPickerUnmountTimerRef.current != null) {
window.clearTimeout(parentPickerUnmountTimerRef.current)
}
},
[]
)
@ -684,7 +692,23 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
window.clearTimeout(parentPickerFallbackTimerRef.current)
parentPickerFallbackTimerRef.current = null
}
if (parentPickerUnmountTimerRef.current != null) {
window.clearTimeout(parentPickerUnmountTimerRef.current)
parentPickerUnmountTimerRef.current = null
}
setParentPicker(pendingParentPicker)
setParentPickerOpen(true)
}, [])
const handleParentPickerOpenChange = useCallback((open: boolean) => {
if (open) {
return
}
setParentPickerOpen(false)
parentPickerUnmountTimerRef.current = window.setTimeout(() => {
parentPickerUnmountTimerRef.current = null
setParentPicker(null)
}, PARENT_PICKER_EXIT_ANIMATION_MS)
}, [])
const handleOpenParentPicker = useCallback(
@ -1063,16 +1087,18 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
onOpenChange={setCreateGroupDialogOpen}
onSubmit={handleSubmitNewProjectGroup}
/>
<WorktreeParentPickerPopover
open={parentPicker !== null}
childWorktreeId={parentPicker?.childWorktreeId ?? null}
anchorElement={parentPicker?.anchorElement ?? null}
onOpenChange={(open) => {
if (!open) {
setParentPicker(null)
}
}}
/>
{/* Why: mounted only while open one instance of this lives behind every
worktree card, and each one subscribes to the worktree and lineage
maps just to compute parent candidates it will never show. Closing
flips `open` first so the exit animation runs, then unmounts. */}
{parentPicker ? (
<WorktreeParentPickerPopover
open={parentPickerOpen}
childWorktreeId={parentPicker.childWorktreeId}
anchorElement={parentPicker.anchorElement}
onOpenChange={handleParentPickerOpenChange}
/>
) : null}
</div>
)
})

View File

@ -1,9 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import {
getWorktreeParentPickerItemValue,
handleWorktreeParentPickerKeyDown,
selectWorktreeParent
} from './WorktreeParentPickerPopover'
import {
clampWorktreeParentPickerIndex,
filterWorktreeParentCandidates,
getWorktreeParentPickerItemValue
} from './worktree-parent-picker-filtering'
import {
clampWorktreeParentPickerAnchorTop,
estimateWorktreeParentPickerHeight
} from './worktree-parent-picker-placement'
afterEach(() => {
vi.restoreAllMocks()
@ -96,3 +105,100 @@ describe('getWorktreeParentPickerItemValue', () => {
expect(getWorktreeParentPickerItemValue(makeWorktree())).toContain('/workspaces/parent')
})
})
describe('filterWorktreeParentCandidates', () => {
const alpha = makeWorktree({ id: 'alpha', displayName: 'alpha', path: '/workspaces/alpha' })
const beta = makeWorktree({
id: 'beta',
displayName: 'beta',
path: '/workspaces/beta',
branch: 'refs/heads/feature/alpha-follow-up'
})
it('returns every candidate when the search is blank', () => {
expect(filterWorktreeParentCandidates([alpha, beta], ' ')).toEqual([alpha, beta])
})
it('drops non-matching candidates and ranks the closest match first', () => {
expect(filterWorktreeParentCandidates([beta, alpha], 'alpha')).toEqual([alpha, beta])
expect(filterWorktreeParentCandidates([alpha, beta], 'nothing-matches')).toEqual([])
})
it('matches on branch and path, not just display name', () => {
expect(filterWorktreeParentCandidates([alpha, beta], 'follow-up')).toEqual([beta])
expect(filterWorktreeParentCandidates([alpha, beta], '/workspaces/beta')).toEqual([beta])
})
})
describe('estimateWorktreeParentPickerHeight', () => {
it('grows with the candidate count up to the list cap', () => {
expect(estimateWorktreeParentPickerHeight(1)).toBe(79 + 56)
expect(estimateWorktreeParentPickerHeight(3)).toBe(79 + 168)
// Why: matches the height measured on a rendered picker in the dev app.
expect(estimateWorktreeParentPickerHeight(300)).toBe(367)
})
it('reserves a single row when nothing is eligible', () => {
expect(estimateWorktreeParentPickerHeight(0)).toBe(79 + 56)
})
})
describe('clampWorktreeParentPickerAnchorTop', () => {
it('leaves an anchor that already fits where it is', () => {
expect(clampWorktreeParentPickerAnchorTop(200, 333, 900)).toBe(200)
})
it('lifts an anchor whose popover would run off the bottom', () => {
expect(clampWorktreeParentPickerAnchorTop(800, 333, 900)).toBe(900 - 12 - 333)
})
it('keeps an anchor above the window from riding off the top', () => {
expect(clampWorktreeParentPickerAnchorTop(-40, 333, 900)).toBe(12)
})
it('pins to the top padding when the window is shorter than the popover', () => {
expect(clampWorktreeParentPickerAnchorTop(120, 333, 300)).toBe(12)
})
})
describe('clampWorktreeParentPickerIndex', () => {
it('keeps the highlight inside the filtered result window', () => {
expect(clampWorktreeParentPickerIndex(5, 3)).toBe(2)
expect(clampWorktreeParentPickerIndex(-1, 3)).toBe(0)
expect(clampWorktreeParentPickerIndex(1, 3)).toBe(1)
})
it('collapses to zero when nothing matches', () => {
expect(clampWorktreeParentPickerIndex(4, 0)).toBe(0)
})
})
describe('parent picker keyboard input', () => {
it.each([
{ isComposing: true, keyCode: 13 },
{ isComposing: false, keyCode: 229 }
])('leaves IME composition keys to the input method', (nativeEvent) => {
const moveHighlight = vi.fn()
const selectParent = vi.fn()
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
handleWorktreeParentPickerKeyDown({
event: {
key: 'Enter',
nativeEvent,
preventDefault,
stopPropagation
} as unknown as React.KeyboardEvent<HTMLInputElement>,
candidates: [{ id: 'parent' }],
activeIndex: 0,
moveHighlight,
selectParent
})
expect(moveHighlight).not.toHaveBeenCalled()
expect(selectParent).not.toHaveBeenCalled()
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
})

View File

@ -1,22 +1,36 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import React, {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { toast } from 'sonner'
import { GitBranch, Server } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Command, CommandInput, CommandList } from '@/components/ui/command'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useAppStore } from '@/store'
import { useAllWorktrees, useRepoMap, useWorktreeMap } from '@/store/selectors'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { branchDisplayName } from './WorktreeCardHelpers'
import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { useWorktreeActivityStatuses } from './use-worktree-activity-statuses'
import { WorktreeParentPickerRow } from './WorktreeParentPickerRow'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import {
clampWorktreeParentPickerIndex,
filterWorktreeParentCandidates
} from './worktree-parent-picker-filtering'
import {
clampWorktreeParentPickerAnchorTop,
estimateWorktreeParentPickerHeight,
PICKER_ROW_HEIGHT,
PICKER_ROW_OVERSCAN,
PICKER_LIST_MAX_HEIGHT,
PICKER_VIEWPORT_PADDING
} from './worktree-parent-picker-placement'
import { translate } from '@/i18n/i18n'
import type { Worktree } from '../../../../shared/types'
type WorktreeParentPickerPopoverProps = {
open: boolean
@ -35,12 +49,16 @@ type SelectParentArgs = {
showError: (message: string) => void
}
function getAnchorRect(anchorElement: HTMLElement | null): AnchorRect | null {
return anchorElement?.getBoundingClientRect() ?? null
type WorktreeParentPickerKeyboardArgs = {
event: React.KeyboardEvent<HTMLInputElement>
candidates: readonly { id: string }[]
activeIndex: number
moveHighlight: (index: number) => void
selectParent: (worktreeId: string) => void
}
export function getWorktreeParentPickerItemValue(candidate: Worktree): string {
return `${candidate.displayName} ${branchDisplayName(candidate.branch)} ${candidate.path}`
function getAnchorRect(anchorElement: HTMLElement | null): AnchorRect | null {
return anchorElement?.getBoundingClientRect() ?? null
}
export function selectWorktreeParent({
@ -65,42 +83,37 @@ export function selectWorktreeParent({
})
}
function WorktreeParentPickerRow({
candidate,
isCurrent
}: {
candidate: Worktree
isCurrent: boolean
}): React.JSX.Element {
const repo = useRepoMap().get(candidate.repoId)
const branch = branchDisplayName(candidate.branch)
return (
<div className="flex min-w-0 flex-1 items-start gap-2">
<WorktreeActivityStatusIndicator worktreeId={candidate.id} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium">{candidate.displayName}</span>
{isCurrent ? (
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-px text-[9px] font-medium leading-none text-muted-foreground">
{translate('auto.components.sidebar.WorktreeParentPickerPopover.current', 'Current')}
</span>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
{repo ? (
<span className="inline-flex min-w-0 max-w-[8rem] shrink-0 items-center gap-1 rounded border border-border bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-foreground">
<RepoBadgeMark color={repo.badgeColor} />
<span className="truncate lowercase">{repo.displayName}</span>
</span>
) : null}
{repo?.connectionId ? <Server className="size-3 shrink-0" /> : null}
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{branch}</span>
</div>
</div>
</div>
)
export function handleWorktreeParentPickerKeyDown({
event,
candidates,
activeIndex,
moveHighlight,
selectParent
}: WorktreeParentPickerKeyboardArgs): void {
if (isImeCompositionKeyDown(event) || candidates.length === 0) {
return
}
const navigate = (nextIndex: number): void => {
event.preventDefault()
event.stopPropagation()
moveHighlight(clampWorktreeParentPickerIndex(nextIndex, candidates.length))
}
if (event.key === 'ArrowDown') {
navigate(activeIndex + 1)
} else if (event.key === 'ArrowUp') {
navigate(activeIndex - 1)
} else if (event.key === 'Home') {
navigate(0)
} else if (event.key === 'End') {
navigate(candidates.length - 1)
} else if (event.key === 'Enter') {
const candidate = candidates[activeIndex]
if (candidate) {
event.preventDefault()
event.stopPropagation()
selectParent(candidate.id)
}
}
}
export function WorktreeParentPickerPopover({
@ -116,9 +129,15 @@ export function WorktreeParentPickerPopover({
const lineageById = useAppStore((s) => s.worktreeLineageById)
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
const suppressInitialOutsideCloseRef = useRef(false)
const listRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const optionIdPrefix = `${useId()}option`
const [search, setSearch] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const [anchorRect, setAnchorRect] = useState<AnchorRect | null>(() =>
getAnchorRect(anchorElement)
)
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight)
const child = childWorktreeId ? worktreeMap.get(childWorktreeId) : undefined
const candidates = useMemo(
() =>
@ -138,7 +157,10 @@ export function WorktreeParentPickerPopover({
if (!open) {
return
}
const updateAnchorRect = (): void => setAnchorRect(getAnchorRect(anchorElement))
const updateAnchorRect = (): void => {
setAnchorRect(getAnchorRect(anchorElement))
setViewportHeight(window.innerHeight)
}
updateAnchorRect()
window.addEventListener('resize', updateAnchorRect)
window.addEventListener('scroll', updateAnchorRect, true)
@ -175,63 +197,189 @@ export function WorktreeParentPickerPopover({
[assignWorktreeParent, childWorktreeId, onOpenChange]
)
// Why: a `position: fixed` anchor element would be laid out against the
// sidebar's transformed virtual-row container, not the viewport, so its
// viewport coordinates landed a full row-offset too low. A virtual anchor
// renders no node and hands Radix the measured rect directly.
const virtualAnchorRef = useMemo(() => {
if (!anchorRect) {
return undefined
}
const top = clampWorktreeParentPickerAnchorTop(
anchorRect.top,
// Why: measured from the full candidate list, not the filtered one, so
// the popover does not jump around while the user types.
estimateWorktreeParentPickerHeight(candidates.length),
viewportHeight
)
const rect = new DOMRect(anchorRect.left, top, anchorRect.width, anchorRect.height)
return { current: { getBoundingClientRect: () => rect } }
}, [anchorRect, candidates.length, viewportHeight])
const filtered = useMemo(
() => filterWorktreeParentCandidates(candidates, search),
[candidates, search]
)
const activeIndex = clampWorktreeParentPickerIndex(highlightedIndex, filtered.length)
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => listRef.current,
estimateSize: () => PICKER_ROW_HEIGHT,
overscan: PICKER_ROW_OVERSCAN,
getItemKey: (index) => filtered[index]?.id ?? index,
// Why: the list mounts inside a popover that measures on the next frame, so
// seed the viewport with max-h-72 to avoid a blank first paint.
initialRect: { width: 0, height: PICKER_LIST_MAX_HEIGHT }
})
const handleSearchChange = useCallback(
(nextSearch: string) => {
// Why: re-ranking on each keystroke makes any prior highlight meaningless.
setSearch(nextSearch)
setHighlightedIndex(0)
virtualizer.scrollToOffset(0)
},
[virtualizer]
)
const virtualRows = virtualizer.getVirtualItems()
// Why: the hook memoizes its store selector on this array's identity, so a
// fresh array each render would rebuild the status map on every render.
const visibleWorktreeIds = useMemo(
() =>
virtualRows
.map((row) => filtered[row.index]?.id)
.filter((id): id is string => id !== undefined),
[filtered, virtualRows]
)
const statuses = useWorktreeActivityStatuses(visibleWorktreeIds)
const moveHighlight = useCallback(
(nextIndex: number) => {
setHighlightedIndex(nextIndex)
virtualizer.scrollToIndex(nextIndex, { align: 'auto' })
},
[virtualizer]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
// Why: cmdk's root key handler navigates the items it has mounted, which
// is only the virtual window here — own the navigation instead and keep
// the event from reaching it.
handleWorktreeParentPickerKeyDown({
event,
candidates: filtered,
activeIndex,
moveHighlight,
selectParent: handleSelect
})
},
[activeIndex, filtered, handleSelect, moveHighlight]
)
// Why: cmdk's Input owns aria-activedescendant and points it at its own item
// registry, which is empty while we drive selection. Re-point it after every
// commit so assistive tech still tracks the highlighted row.
useEffect(() => {
const input = inputRef.current
if (!input) {
return
}
const activeOptionId = filtered.length > 0 ? `${optionIdPrefix}-${activeIndex}` : null
if (activeOptionId) {
input.setAttribute('aria-activedescendant', activeOptionId)
} else {
input.removeAttribute('aria-activedescendant')
}
})
if (!child || !anchorRect) {
return null
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor asChild>
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: anchorRect.left,
top: anchorRect.top,
width: anchorRect.width,
height: anchorRect.height
}}
/>
</PopoverAnchor>
<PopoverAnchor virtualRef={virtualAnchorRef} />
<PopoverContent
align="start"
side="right"
sideOffset={8}
className="w-80 p-0"
collisionPadding={PICKER_VIEWPORT_PADDING}
className="flex max-h-(--radix-popover-content-available-height) w-80 flex-col p-0"
onInteractOutside={(event) => {
if (suppressInitialOutsideCloseRef.current) {
event.preventDefault()
}
}}
>
<Command>
<div className="flex min-w-0 shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2 text-[11px] leading-none text-muted-foreground">
<span className="shrink-0">
{translate(
'auto.components.sidebar.WorktreeParentPickerPopover.setParentFor',
'Set parent for'
)}
</span>
<span className="truncate font-medium text-foreground">{child.displayName}</span>
</div>
<Command shouldFilter={false} className="min-h-0">
<CommandInput
ref={inputRef}
value={search}
onValueChange={handleSearchChange}
onKeyDown={handleKeyDown}
wrapperClassName="shrink-0"
placeholder={translate(
'auto.components.sidebar.WorktreeParentPickerPopover.searchPlaceholder',
'Search worktrees...'
)}
autoFocus
/>
<CommandList className="max-h-72">
<CommandEmpty>
{translate(
'auto.components.sidebar.WorktreeParentPickerPopover.empty',
'No matching eligible worktrees.'
)}
</CommandEmpty>
{candidates.map((candidate) => (
<CommandItem
key={candidate.id}
value={getWorktreeParentPickerItemValue(candidate)}
onSelect={() => handleSelect(candidate.id)}
className="items-start px-2 py-2"
>
<WorktreeParentPickerRow
candidate={candidate}
isCurrent={activeWorktreeId === candidate.id}
/>
</CommandItem>
))}
<CommandList ref={listRef} className="max-h-72 min-h-0 flex-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeParentPickerPopover.empty',
'No matching eligible worktrees.'
)}
</div>
) : (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualRows.map((virtualRow) => {
const candidate = filtered[virtualRow.index]
if (!candidate) {
return null
}
const isHighlighted = virtualRow.index === activeIndex
return (
<div
key={candidate.id}
id={`${optionIdPrefix}-${virtualRow.index}`}
role="option"
aria-selected={isHighlighted}
data-selected={isHighlighted || undefined}
className={cn(
'absolute left-0 top-0 flex w-full cursor-default select-none items-start gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none',
isHighlighted && 'bg-accent text-accent-foreground'
)}
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`
}}
onPointerMove={() => setHighlightedIndex(virtualRow.index)}
onClick={() => handleSelect(candidate.id)}
>
<WorktreeParentPickerRow
candidate={candidate}
repo={repoMap.get(candidate.repoId)}
status={statuses.get(candidate.id) ?? 'inactive'}
isCurrent={activeWorktreeId === candidate.id}
/>
</div>
)
})}
</div>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@ -0,0 +1,52 @@
import React from 'react'
import { GitBranch, Server } from 'lucide-react'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
import { translate } from '@/i18n/i18n'
import { branchDisplayName } from './WorktreeCardHelpers'
import StatusIndicator from './StatusIndicator'
import type { Repo, Worktree } from '../../../../shared/types'
// Why: presentational and memoized — the picker resolves every row's status
// from one batched store read so the rows themselves hold no subscriptions.
export const WorktreeParentPickerRow = React.memo(function WorktreeParentPickerRow({
candidate,
repo,
status,
isCurrent
}: {
candidate: Worktree
repo: Pick<Repo, 'badgeColor' | 'connectionId' | 'displayName'> | undefined
status: WorktreeStatus
isCurrent: boolean
}): React.JSX.Element {
const branch = branchDisplayName(candidate.branch)
return (
<div className="flex min-w-0 flex-1 items-start gap-2">
<StatusIndicator status={status} aria-hidden="true" className="mt-0.5" />
<span className="sr-only">{getWorktreeStatusLabel(status)}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium">{candidate.displayName}</span>
{isCurrent ? (
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-px text-[9px] font-medium leading-none text-muted-foreground">
{translate('auto.components.sidebar.WorktreeParentPickerPopover.current', 'Current')}
</span>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
{repo ? (
<span className="inline-flex min-w-0 max-w-[8rem] shrink-0 items-center gap-1 rounded border border-border bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-foreground">
<RepoBadgeMark color={repo.badgeColor} />
<span className="truncate lowercase">{repo.displayName}</span>
</span>
) : null}
{repo?.connectionId ? <Server className="size-3 shrink-0" /> : null}
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{branch}</span>
</div>
</div>
</div>
)
})

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { shallow } from 'zustand/shallow'
import { selectWorktreeActivityStatuses } from './use-worktree-activity-statuses'
type StatusState = Parameters<typeof selectWorktreeActivityStatuses>[0]
function makeStatusState(): StatusState {
return {
tabsByWorktree: {},
browserTabsByWorktree: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {}
}
}
describe('selectWorktreeActivityStatuses', () => {
it('stays shallow-equal when an unrelated worktree receives activity updates', () => {
const state = makeStatusState()
const unrelatedUpdate: StatusState = {
...state,
agentStatusEpoch: 1,
browserTabsByWorktree: {
other: []
},
runtimePaneTitlesByTabId: {
'other-tab': { 0: 'codex [working]' }
},
ptyIdsByTabId: {
'other-tab': ['other-pty']
}
}
expect(
shallow(
selectWorktreeActivityStatuses(state, ['visible']),
selectWorktreeActivityStatuses(unrelatedUpdate, ['visible'])
)
).toBe(true)
})
})

View File

@ -0,0 +1,69 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore, type AppState } from '@/store'
import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers'
import {
selectLivePtyIdsForWorktree,
selectTerminalLayoutRootsForWorktree,
selectRuntimePaneTitlesForWorktree
} from './worktree-card-status-inputs'
import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
type WorktreeActivityStatusState = Pick<
AppState,
| 'tabsByWorktree'
| 'browserTabsByWorktree'
| 'runtimePaneTitlesByTabId'
| 'ptyIdsByTabId'
| 'terminalLayoutsByTabId'
| 'agentStatusEpoch'
| 'agentStatusByPaneKey'
| 'migrationUnsupportedByPtyId'
| 'retainedAgentsByPaneKey'
| 'runtimeAgentOrchestrationByPaneKey'
>
export function selectWorktreeActivityStatuses(
statusInputs: WorktreeActivityStatusState,
worktreeIds: readonly string[]
): Map<string, WorktreeStatus> {
const statuses = new Map<string, WorktreeStatus>()
for (const worktreeId of worktreeIds) {
const {
hasPermission,
hasLiveWorking,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
statuses.set(
worktreeId,
resolveWorktreeStatus({
tabs: statusInputs.tabsByWorktree[worktreeId] ?? EMPTY_TABS,
browserTabs: statusInputs.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
ptyIdsByTabId: selectLivePtyIdsForWorktree(statusInputs, worktreeId),
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId),
agentStatusPaneIdsByTabId,
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
hasPermission,
hasLiveWorking,
hasLiveDone,
hasRetainedDone
})
)
}
return statuses
}
// Why: return a shallow-stable status map so terminal updates outside the
// visible candidates do not re-render the picker.
export function useWorktreeActivityStatuses(
worktreeIds: readonly string[]
): Map<string, WorktreeStatus> {
const selectStatuses = useCallback(
(state: WorktreeActivityStatusState) => selectWorktreeActivityStatuses(state, worktreeIds),
[worktreeIds]
)
return useAppStore(useShallow(selectStatuses))
}

View File

@ -0,0 +1,35 @@
import { defaultFilter } from 'cmdk'
import { branchDisplayName } from './WorktreeCardHelpers'
import type { Worktree } from '../../../../shared/types'
export function getWorktreeParentPickerItemValue(candidate: Worktree): string {
return `${candidate.displayName} ${branchDisplayName(candidate.branch)} ${candidate.path}`
}
// Why: a repo with hundreds of workspaces makes every eligible sibling a
// candidate, so the list is scored here rather than handed to cmdk — cmdk
// mounts, scores and DOM-reorders every registered item on each keystroke.
export function filterWorktreeParentCandidates(
candidates: readonly Worktree[],
search: string
): Worktree[] {
const query = search.trim()
if (!query) {
return [...candidates]
}
return candidates
.map((candidate) => ({
candidate,
score: defaultFilter(getWorktreeParentPickerItemValue(candidate), query, [])
}))
.filter((scored) => scored.score > 0)
.sort((a, b) => b.score - a.score)
.map((scored) => scored.candidate)
}
export function clampWorktreeParentPickerIndex(index: number, count: number): number {
if (count <= 0) {
return 0
}
return Math.min(Math.max(index, 0), count - 1)
}

View File

@ -0,0 +1,34 @@
export const PICKER_ROW_HEIGHT = 56
export const PICKER_ROW_OVERSCAN = 6
export const PICKER_LIST_MAX_HEIGHT = 288
export const PICKER_VIEWPORT_PADDING = 12
// Why: input is h-10 inside a py-1 wrapper over a 1px border.
const PICKER_INPUT_HEIGHT = 49
// Why: 11px leading-none text in a py-2 row over a 1px border.
const PICKER_HEADER_HEIGHT = 28
// Why: the popover surface adds its own 1px border top and bottom.
const PICKER_SURFACE_BORDER_HEIGHT = 2
export function estimateWorktreeParentPickerHeight(candidateCount: number): number {
const listHeight = Math.min(
Math.max(candidateCount, 1) * PICKER_ROW_HEIGHT,
PICKER_LIST_MAX_HEIGHT
)
return PICKER_SURFACE_BORDER_HEIGHT + PICKER_HEADER_HEIGHT + PICKER_INPUT_HEIGHT + listHeight
}
// Why: Radix shifts a side="right" popover along its main (horizontal) axis
// only — `crossAxis: false` — and `flip` only swaps left/right, so nothing
// corrects vertical overflow and a card low in the sidebar puts the list below
// the window. The anchor is virtual here, so lift it until the list fits.
export function clampWorktreeParentPickerAnchorTop(
anchorTop: number,
contentHeight: number,
viewportHeight: number
): number {
const maxTop = viewportHeight - PICKER_VIEWPORT_PADDING - contentHeight
if (maxTop <= PICKER_VIEWPORT_PADDING) {
return PICKER_VIEWPORT_PADDING
}
return Math.min(Math.max(anchorTop, PICKER_VIEWPORT_PADDING), maxTop)
}

View File

@ -5015,7 +5015,8 @@
"failedSetParent": "Failed to set parent worktree",
"current": "Current",
"searchPlaceholder": "Search worktrees...",
"empty": "No matching eligible worktrees."
"empty": "No matching eligible worktrees.",
"setParentFor": "Set parent for"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Branch"

View File

@ -4944,7 +4944,8 @@
"failedSetParent": "No se pudo establecer el worktree padre",
"current": "Actual",
"searchPlaceholder": "Buscar worktrees...",
"empty": "No hay worktrees elegibles que coincidan."
"empty": "No hay worktrees elegibles que coincidan.",
"setParentFor": "Establecer padre para"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Rama"

View File

@ -4944,7 +4944,8 @@
"failedSetParent": "親ワークツリーを設定できませんでした",
"current": "現在",
"searchPlaceholder": "ワークツリーを検索...",
"empty": "一致する対象ワークツリーがありません。"
"empty": "一致する対象ワークツリーがありません。",
"setParentFor": "次のワークツリーの親を設定"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "ブランチ"

View File

@ -4944,7 +4944,8 @@
"failedSetParent": "상위 워크트리를 설정하지 못했습니다",
"current": "현재",
"searchPlaceholder": "워크트리 검색...",
"empty": "일치하는 적격 워크트리가 없습니다."
"empty": "일치하는 적격 워크트리가 없습니다.",
"setParentFor": "다음 워크트리의 상위 설정"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "브랜치"

View File

@ -4956,7 +4956,8 @@
"failedSetParent": "无法设置父工作树",
"current": "当前",
"searchPlaceholder": "搜索工作树...",
"empty": "没有匹配的可用工作树。"
"empty": "没有匹配的可用工作树。",
"setParentFor": "为以下工作树设置父级"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "分支"