From 5675539ef0a3c9a91e39ab1e1e267e4e7c82915b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 7 May 2026 19:44:51 +0800 Subject: [PATCH] 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 --- .../src/components/chat/ChatInput.test.tsx | 3 +- .../shared/RepositoryLaunchControls.tsx | 117 +++++++++++++++++- desktop/src/i18n/locales/en.ts | 2 +- desktop/src/i18n/locales/zh.ts | 2 +- desktop/src/pages/EmptySession.test.tsx | 3 + 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 75741505..cdba591d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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 } }) diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx index 693bc2e4..1159fe07 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -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(null) const branchButtonRef = useRef(null) + const worktreeButtonRef = useRef(null) const menuRef = useRef(null) + const worktreeMenuRef = useRef(null) const searchRef = useRef(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) => { 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({ )} @@ -415,6 +464,62 @@ export function RepositoryLaunchControls({ , document.body, )} + + {worktreeMenuOpen && worktreeMenuPos && createPortal( +
+
+ + + +
+
, + document.body, + )} ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 57d030e5..f85b59eb 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 90f03b90..154b64a4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -707,7 +707,7 @@ export const zh: Record = { 'repoLaunch.checkedOut': '已在其他工作树中检出', 'repoLaunch.worktreeCurrent': '当前工作树', 'repoLaunch.worktreeIsolated': '独立工作树', - 'repoLaunch.toggleWorktree': '切换工作树隔离', + 'repoLaunch.selectWorktree': '选择工作树模式', 'repoLaunch.notGit': '当前项目不是 Git 仓库。', 'repoLaunch.missingWorkdir': '工作目录不存在。', 'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。', diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 0f35cfda..4e1d7583 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -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(() => {