fix(desktop): confirm newly created launch branches

Close the launch menu after creation, keep the new branch selected, and explain when checkout or worktree setup occurs.
This commit is contained in:
程序员阿江(Relakkes)
2026-08-07 23:19:16 +08:00
parent cfb7e224b4
commit dea5d9bc67
7 changed files with 77 additions and 11 deletions
@@ -13,6 +13,10 @@ const apiMocks = vi.hoisted(() => ({
createRepositoryBranch: vi.fn(),
}))
const uiMocks = vi.hoisted(() => ({
addToast: vi.fn(),
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
@@ -29,6 +33,12 @@ vi.mock('../../api/sessions', () => ({
},
}))
vi.mock('../../stores/uiStore', () => ({
useUIStore: (selector: (state: { addToast: typeof uiMocks.addToast }) => unknown) => (
selector({ addToast: uiMocks.addToast })
),
}))
// Must match the component's own import specifier, or the mock silently does
// nothing and the real panel renders. It moved to composite/ in the directory
// reshuffle and this path was left behind.
@@ -68,6 +78,8 @@ vi.mock('../../i18n', () => ({
'repoLaunch.newBranchNameLabel': 'Branch name',
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchSubmit': 'Create',
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
'repoLaunch.newBranchTitle': 'New branch',
'repoLaunch.noBranch': 'No branch',
'repoLaunch.noBranchMatch': 'No matching branches',
@@ -182,9 +194,16 @@ function renderControls(props: Partial<ComponentProps<typeof RepositoryLaunchCon
* happens *after* a directory is picked needs the prop to actually come back
* down changed.
*/
function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string }) {
function ControlledHarness({
initialWorkDir = '',
initialUseWorktree = false,
}: {
initialWorkDir?: string
initialUseWorktree?: boolean
}) {
const [workDir, setWorkDir] = useState(initialWorkDir)
const [branch, setBranch] = useState<string | null>(null)
const [useWorktree, setUseWorktree] = useState(initialUseWorktree)
return (
<RepositoryLaunchControls
@@ -192,8 +211,8 @@ function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string })
onWorkDirChange={setWorkDir}
branch={branch}
onBranchChange={setBranch}
useWorktree={false}
onUseWorktreeChange={vi.fn()}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
/>
)
}
@@ -226,6 +245,7 @@ describe('RepositoryLaunchControls', () => {
apiMocks.getRepositoryContext.mockReset()
apiMocks.getRepositoryContext.mockResolvedValue(okRepositoryContext)
apiMocks.createRepositoryBranch.mockReset()
uiMocks.addToast.mockReset()
apiMocks.createRepositoryBranch.mockResolvedValue({
branch: 'feature/new',
baseRef: 'main',
@@ -536,17 +556,41 @@ describe('RepositoryLaunchControls', () => {
})
})
// The pill switches to the new branch, and the list it came back with is
// used as-is — re-reading the repository would be a second round trip.
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
.toBeInTheDocument()
// Success closes the menu so the selected branch is immediately visible
// on the pill instead of leaving the user inside an apparently unchanged
// menu. The returned list is adopted without a second request.
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
expect(apiMocks.getRepositoryContext).toHaveBeenCalledTimes(1)
expect(uiMocks.addToast).toHaveBeenCalledWith({
type: 'success',
message: 'Created and selected “feature/new”. It will be checked out when the session starts.',
})
fireEvent.click(pill)
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
expect(within(listbox).getByRole('option', { name: /feature\/new/ })).toBeInTheDocument()
})
it('explains that an isolated worktree starts from the new branch', async () => {
render(<ControlledHarness initialWorkDir="/repo" initialUseWorktree />)
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature/new' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(uiMocks.addToast).toHaveBeenCalledWith({
type: 'success',
message: 'Created and selected “feature/new”. An isolated worktree will be created from it when the session starts.',
})
})
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
.toBeInTheDocument()
expect(screen.getByText('Isolated')).toBeInTheDocument()
})
// Creating off `main` is also what `from: context.currentBranch` would do,
// so the default-selection test cannot tell the two apart — it stayed green
// under exactly that mutation. This drives a non-default selection, which is
@@ -18,6 +18,7 @@ import {
type RepositoryContextResult,
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
import { useDismissable } from '@/hooks/useDismissable'
import { useMobileViewport } from '../../hooks/useMobileViewport'
@@ -50,7 +51,8 @@ const VIEWPORT_GUTTER = 12
* `root` lists directory, branch and the worktree modes; `directory` and
* `branch` are its two drill-downs. The menu opens on `root` only when there
* is a repo to describe — see `viewOnOpen`. `newBranch` is reached from the
* branch list and returns to it.
* branch list; cancelling returns there, while success closes the menu so the
* newly selected branch is immediately visible in the pill.
*/
type MenuView = 'directory' | 'root' | 'branch' | 'newBranch'
@@ -139,6 +141,7 @@ export function RepositoryLaunchControls({
placement = 'outside',
}: Props) {
const t = useTranslation()
const addToast = useUIStore((state) => state.addToast)
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const isToolbar = placement === 'toolbar' && !isMobileBrowser
const [context, setContext] = useState<RepositoryContextResult | null>(null)
@@ -363,9 +366,18 @@ export function RepositoryLaunchControls({
setContext(result.context)
setError(null)
onBranchChange(result.branch)
setNewBranchName('')
setBranchFilter('')
setView('root')
addToast({
type: 'success',
message: t(
useWorktree
? 'repoLaunch.newBranchSuccessIsolated'
: 'repoLaunch.newBranchSuccessCurrent',
{ branch: result.branch },
),
})
// Closing exposes the pill, which now names the branch selected for launch.
// Keeping the menu open made a successful create look like a no-op.
closeMenu()
} catch (err) {
if (latestWorkDirRef.current !== requestWorkDir) return
const code = err instanceof Error && 'body' in err
+2
View File
@@ -1647,6 +1647,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchFrom': 'Starts from {branch}',
'repoLaunch.newBranchSubmit': 'Create',
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
'repoLaunch.newBranchErrorInvalid': 'Git will not accept this branch name.',
'repoLaunch.newBranchErrorExists': 'A branch with this name already exists.',
'repoLaunch.newBranchErrorFailed': 'Could not create the branch.',
+2
View File
@@ -1649,6 +1649,8 @@ export const jp: Record<TranslationKey, string> = {
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchFrom': '{branch} から作成',
'repoLaunch.newBranchSubmit': '作成',
'repoLaunch.newBranchSuccessCurrent': '「{branch}」を作成して選択しました。セッション開始時にこのブランチへ切り替えます。',
'repoLaunch.newBranchSuccessIsolated': '「{branch}」を作成して選択しました。セッション開始時にこのブランチから独立した worktree を作成します。',
'repoLaunch.newBranchErrorInvalid': 'Git はこのブランチ名を受け付けません。',
'repoLaunch.newBranchErrorExists': '同じ名前のブランチが既に存在します。',
'repoLaunch.newBranchErrorFailed': 'ブランチを作成できませんでした。',
+2
View File
@@ -1649,6 +1649,8 @@ export const kr: Record<TranslationKey, string> = {
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchFrom': '{branch} 기준으로 생성',
'repoLaunch.newBranchSubmit': '생성',
'repoLaunch.newBranchSuccessCurrent': '“{branch}” 브랜치를 만들고 선택했습니다. 세션을 시작할 때 이 브랜치로 전환합니다.',
'repoLaunch.newBranchSuccessIsolated': '“{branch}” 브랜치를 만들고 선택했습니다. 세션을 시작할 때 이 브랜치를 기준으로 독립 worktree를 만듭니다.',
'repoLaunch.newBranchErrorInvalid': 'Git이 이 브랜치 이름을 허용하지 않습니다.',
'repoLaunch.newBranchErrorExists': '같은 이름의 브랜치가 이미 있습니다.',
'repoLaunch.newBranchErrorFailed': '브랜치를 만들지 못했습니다.',
+2
View File
@@ -1648,6 +1648,8 @@ export const zh: Record<TranslationKey, string> = {
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchFrom': '以 {branch} 為基礎建立',
'repoLaunch.newBranchSubmit': '建立',
'repoLaunch.newBranchSuccessCurrent': '已建立並選取「{branch}」,工作階段啟動時將切換到該分支。',
'repoLaunch.newBranchSuccessIsolated': '已建立並選取「{branch}」,工作階段啟動時將以它為基礎建立獨立工作樹。',
'repoLaunch.newBranchErrorInvalid': 'Git 不接受這個分支名稱。',
'repoLaunch.newBranchErrorExists': '同名分支已存在。',
'repoLaunch.newBranchErrorFailed': '建立分支失敗。',
+2
View File
@@ -1648,6 +1648,8 @@ export const zh: Record<TranslationKey, string> = {
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchFrom': '基于 {branch} 创建',
'repoLaunch.newBranchSubmit': '创建',
'repoLaunch.newBranchSuccessCurrent': '已创建并选中“{branch}”,会话启动时将切换到该分支。',
'repoLaunch.newBranchSuccessIsolated': '已创建并选中“{branch}”,会话启动时将基于它创建独立工作树。',
'repoLaunch.newBranchErrorInvalid': 'Git 不接受这个分支名。',
'repoLaunch.newBranchErrorExists': '同名分支已存在。',
'repoLaunch.newBranchErrorFailed': '创建分支失败。',