fix: make worktree launch mode selectable

The worktree control is a mode choice, so it should behave like the branch selector instead of a hidden toggle. This adds a compact dropdown with current and isolated worktree options while preserving the existing launch layout and isolation guard.

Constraint: Keep the composer context rail layout unchanged.
Rejected: Keep the single-click toggle | the interaction hid the available modes and made the state harder to reason about.
Confidence: high
Scope-risk: narrow
Directive: Worktree mode must stay an explicit selection surface, not a hidden toggle.
Tested: cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/shared/DirectoryPicker.test.tsx
Tested: bun run check:desktop
Tested: agent-browser worktree dropdown desktop and mobile screenshots at /tmp/cc-haha-worktree-dropdown-menu.png, /tmp/cc-haha-worktree-dropdown-selected.png, /tmp/cc-haha-worktree-dropdown-mobile-menu.png, /tmp/cc-haha-worktree-dropdown-mobile-selected.png
Not-tested: Native Tauri packaged window visual check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-07 19:44:51 +08:00
parent ca4900ed0c
commit 5675539ef0
5 changed files with 118 additions and 9 deletions

View File

@ -354,7 +354,8 @@ describe('ChatInput file mentions', () => {
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
fireEvent.click(screen.getByRole('button', { name: 'Toggle worktree isolation' }))
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Current worktree/ }))
fireEvent.click(await screen.findByRole('option', { name: 'Isolated worktree' }))
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'run in a worktree', selectionStart: 17 } })

View File

@ -30,6 +30,8 @@ type Props = {
const BRANCH_MENU_HEIGHT = 360
const BRANCH_MENU_WIDTH = 390
const WORKTREE_MENU_HEIGHT = 126
const WORKTREE_MENU_WIDTH = 226
const VIEWPORT_GUTTER = 12
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
@ -59,13 +61,18 @@ export function RepositoryLaunchControls({
const [branchFilter, setBranchFilter] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false)
const [worktreeMenuPos, setWorktreeMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const branchButtonRef = useRef<HTMLButtonElement>(null)
const worktreeButtonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const worktreeMenuRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const searchInputId = useId()
const listboxId = useId()
const worktreeListboxId = useId()
const updateMenuPos = useCallback(() => {
if (!branchButtonRef.current) return
@ -81,6 +88,20 @@ export function RepositoryLaunchControls({
})
}, [])
const updateWorktreeMenuPos = useCallback(() => {
if (!worktreeButtonRef.current) return
const rect = worktreeButtonRef.current.getBoundingClientRect()
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
const direction = spaceBelow >= WORKTREE_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - WORKTREE_MENU_WIDTH - VIEWPORT_GUTTER)
setWorktreeMenuPos({
top: direction === 'down' ? rect.bottom + 6 : rect.top - 6,
left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft),
direction,
})
}, [])
useEffect(() => {
if (!workDir) {
setContext(null)
@ -131,17 +152,20 @@ export function RepositoryLaunchControls({
}, [branch, context, onBranchChange])
useEffect(() => {
if (!branchMenuOpen) return
if (!branchMenuOpen && !worktreeMenuOpen) return
const handleClick = (event: MouseEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target)) return
if (menuRef.current?.contains(target)) return
if (worktreeMenuRef.current?.contains(target)) return
setBranchMenuOpen(false)
setWorktreeMenuOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
setBranchMenuOpen(false)
setWorktreeMenuOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKeyDown)
@ -149,7 +173,7 @@ export function RepositoryLaunchControls({
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKeyDown)
}
}, [branchMenuOpen])
}, [branchMenuOpen, worktreeMenuOpen])
useEffect(() => {
if (!branchMenuOpen) return
@ -163,6 +187,17 @@ export function RepositoryLaunchControls({
}
}, [branchMenuOpen, updateMenuPos])
useEffect(() => {
if (!worktreeMenuOpen) return
updateWorktreeMenuPos()
window.addEventListener('scroll', updateWorktreeMenuPos, true)
window.addEventListener('resize', updateWorktreeMenuPos)
return () => {
window.removeEventListener('scroll', updateWorktreeMenuPos, true)
window.removeEventListener('resize', updateWorktreeMenuPos)
}
}, [worktreeMenuOpen, updateWorktreeMenuPos])
useEffect(() => {
setSelectedIndex(0)
}, [branchFilter])
@ -217,6 +252,12 @@ export function RepositoryLaunchControls({
setBranchFilter('')
}
const selectWorktreeMode = (enabled: boolean) => {
if (!enabled && requiresIsolation) return
onUseWorktreeChange(enabled)
setWorktreeMenuOpen(false)
}
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
@ -284,6 +325,7 @@ export function RepositoryLaunchControls({
title={selectedBranch?.name || t('repoLaunch.noBranch')}
onClick={() => {
setBranchMenuOpen((prev) => !prev)
setWorktreeMenuOpen(false)
setBranchFilter('')
}}
className={`${workbarButtonClassName} max-w-[260px]`}
@ -296,12 +338,18 @@ export function RepositoryLaunchControls({
</button>
<button
ref={worktreeButtonRef}
type="button"
disabled={disabled || requiresIsolation}
aria-pressed={useWorktree}
aria-label={t('repoLaunch.toggleWorktree')}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={worktreeMenuOpen}
aria-controls={worktreeMenuOpen ? worktreeListboxId : undefined}
aria-label={`${t('repoLaunch.selectWorktree')}: ${worktreeLabel}`}
title={worktreeLabel}
onClick={() => onUseWorktreeChange(!useWorktree)}
onClick={() => {
setWorktreeMenuOpen((prev) => !prev)
setBranchMenuOpen(false)
}}
className={`${workbarButtonClassName} ${
useWorktree
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
@ -312,6 +360,7 @@ export function RepositoryLaunchControls({
<span className="min-w-0 truncate">
{worktreeLabel}
</span>
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
</>
)}
@ -415,6 +464,62 @@ export function RepositoryLaunchControls({
</div>,
document.body,
)}
{worktreeMenuOpen && worktreeMenuPos && createPortal(
<div
ref={worktreeMenuRef}
className="w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
style={{
position: 'fixed',
left: worktreeMenuPos.left,
...(worktreeMenuPos.direction === 'down'
? { top: worktreeMenuPos.top }
: { bottom: window.innerHeight - worktreeMenuPos.top }),
zIndex: 9999,
}}
>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
<button
type="button"
role="option"
aria-selected={!useWorktree}
aria-disabled={requiresIsolation}
disabled={requiresIsolation}
onClick={() => selectWorktreeMode(false)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeCurrent')}
</span>
</span>
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
<button
type="button"
role="option"
aria-selected={useWorktree}
onClick={() => selectWorktreeMode(true)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeIsolated')}
</span>
</span>
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@ -705,7 +705,7 @@ export const en = {
'repoLaunch.checkedOut': 'Checked out in another worktree',
'repoLaunch.worktreeCurrent': 'Current worktree',
'repoLaunch.worktreeIsolated': 'Isolated worktree',
'repoLaunch.toggleWorktree': 'Toggle worktree isolation',
'repoLaunch.selectWorktree': 'Select worktree mode',
'repoLaunch.notGit': 'Current project is not a Git repository.',
'repoLaunch.missingWorkdir': 'Working directory is missing.',
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',

View File

@ -707,7 +707,7 @@ export const zh: Record<TranslationKey, string> = {
'repoLaunch.checkedOut': '已在其他工作树中检出',
'repoLaunch.worktreeCurrent': '当前工作树',
'repoLaunch.worktreeIsolated': '独立工作树',
'repoLaunch.toggleWorktree': '切换工作树隔离',
'repoLaunch.selectWorktree': '选择工作树模式',
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
'repoLaunch.missingWorkdir': '工作目录不存在。',
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',

View File

@ -344,6 +344,9 @@ describe('EmptySession', () => {
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Isolated worktree/ }))
expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {