diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 8fa5166f..06ff0e06 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -7,7 +7,41 @@ type MessagesResponse = { messages: MessageEntry[] taskNotifications?: AgentTaskNotification[] } -type CreateSessionResponse = { sessionId: string } +type CreateSessionResponse = { sessionId: string; workDir?: string } +export type CreateSessionRepositoryOptions = { + branch?: string | null + worktree?: boolean +} +export type CreateSessionRequest = { + workDir?: string + repository?: CreateSessionRepositoryOptions +} +export type RepositoryBranchInfo = { + name: string + current: boolean + local: boolean + remote: boolean + remoteRef?: string + checkedOut: boolean + worktreePath?: string +} +export type RepositoryWorktreeInfo = { + path: string + branch: string | null + current: boolean +} +export type RepositoryContextResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoRoot: string | null + repoName: string | null + currentBranch: string | null + defaultBranch: string | null + dirty: boolean + branches: RepositoryBranchInfo[] + worktrees: RepositoryWorktreeInfo[] + error?: string +} export type SessionRewindResponse = { target: { targetUserMessageId: string @@ -249,8 +283,11 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/messages`) }, - create(workDir?: string) { - return api.post('/api/sessions', workDir ? { workDir } : {}) + create(input?: string | CreateSessionRequest) { + const body = typeof input === 'string' + ? (input ? { workDir: input } : {}) + : (input ?? {}) + return api.post('/api/sessions', body) }, delete(sessionId: string) { @@ -266,6 +303,11 @@ export const sessionsApi = { return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`) }, + getRepositoryContext(workDir: string) { + const query = new URLSearchParams({ workDir }) + return api.get(`/api/sessions/repository-context?${query.toString()}`) + }, + getGitInfo(sessionId: string) { return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`) }, diff --git a/desktop/src/components/shared/DirectoryPicker.test.tsx b/desktop/src/components/shared/DirectoryPicker.test.tsx new file mode 100644 index 00000000..231a1a1d --- /dev/null +++ b/desktop/src/components/shared/DirectoryPicker.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' + +vi.mock('../../api/sessions', () => ({ + sessionsApi: { + getRecentProjects: vi.fn(), + }, +})) + +vi.mock('../../api/filesystem', () => ({ + filesystemApi: { + browse: vi.fn(), + }, +})) + +import { DirectoryPicker } from './DirectoryPicker' + +describe('DirectoryPicker', () => { + it('uses the source repository name as the fallback label for desktop worktree paths', () => { + render( + , + ) + + expect(screen.getByRole('button')).toHaveTextContent('checkout') + expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678') + }) +}) diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 5cc87d35..ae65e4eb 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -15,11 +15,19 @@ type DirEntry = { name: string; path: string; isDirectory: boolean } let cachedProjects: RecentProject[] | null = null let cacheTimestamp = 0 const CACHE_TTL = 30_000 // 30s +const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' function isTauriRuntime() { return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) } +function projectNameFromPath(filePath: string) { + const displayRoot = filePath.includes(DESKTOP_WORKTREE_MARKER) + ? filePath.slice(0, filePath.indexOf(DESKTOP_WORKTREE_MARKER)) + : filePath + return displayRoot.split('/').filter(Boolean).pop() || filePath +} + export function DirectoryPicker({ value, onChange }: Props) { const t = useTranslation() const [isOpen, setIsOpen] = useState(false) @@ -154,7 +162,7 @@ export function DirectoryPicker({ value, onChange }: Props) { folder )} - {selectedProject?.repoName || selectedProject?.projectName || value.split('/').pop()} + {selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)} {selectedProject?.branch && ( <> diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx new file mode 100644 index 00000000..45fb8285 --- /dev/null +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -0,0 +1,402 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { + AlertCircle, + Check, + ChevronDown, + GitBranch, + GitFork, + Loader2, + Search, +} from 'lucide-react' +import { + sessionsApi, + type RepositoryBranchInfo, + type RepositoryContextResult, +} from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { DirectoryPicker } from './DirectoryPicker' + +type Props = { + workDir: string + onWorkDirChange: (path: string) => void + branch: string | null + onBranchChange: (branch: string | null) => void + useWorktree: boolean + onUseWorktreeChange: (enabled: boolean) => void + onLaunchReadyChange?: (ready: boolean) => void + disabled?: boolean +} + +const BRANCH_MENU_HEIGHT = 360 +const BRANCH_MENU_WIDTH = 390 +const VIEWPORT_GUTTER = 12 + +function stateMessage(context: RepositoryContextResult | null, error: string | null) { + if (error) return error + if (!context) return null + if (context.state === 'not_git_repo') return 'not_git' + if (context.state === 'missing_workdir') return 'missing' + if (context.state === 'error') return context.error || 'error' + return null +} + +export function RepositoryLaunchControls({ + workDir, + onWorkDirChange, + branch, + onBranchChange, + useWorktree, + onUseWorktreeChange, + onLaunchReadyChange, + disabled = false, +}: Props) { + const t = useTranslation() + const [context, setContext] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [branchMenuOpen, setBranchMenuOpen] = useState(false) + const [branchFilter, setBranchFilter] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null) + const rootRef = useRef(null) + const branchButtonRef = useRef(null) + const menuRef = useRef(null) + const searchRef = useRef(null) + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) + const searchInputId = useId() + const listboxId = useId() + + const updateMenuPos = useCallback(() => { + if (!branchButtonRef.current) return + const rect = branchButtonRef.current.getBoundingClientRect() + const spaceAbove = rect.top + const spaceBelow = window.innerHeight - rect.bottom + const direction = spaceBelow >= BRANCH_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up' + const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - BRANCH_MENU_WIDTH - VIEWPORT_GUTTER) + setMenuPos({ + 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) + setError(null) + setLoading(false) + onBranchChange(null) + return + } + + let cancelled = false + setLoading(true) + setError(null) + sessionsApi.getRepositoryContext(workDir) + .then((result) => { + if (cancelled) return + setContext(result) + }) + .catch((err) => { + if (cancelled) return + setContext(null) + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [workDir, onBranchChange]) + + useEffect(() => { + if (context?.state !== 'ok') { + if (context && branch !== null) onBranchChange(null) + return + } + + const branchExists = branch && context.branches.some((candidate) => candidate.name === branch) + if (branchExists) return + + const fallbackBranch = [ + context.currentBranch, + context.defaultBranch, + context.branches[0]?.name, + ].find((name) => name && context.branches.some((candidate) => candidate.name === name)) + + onBranchChange(fallbackBranch || null) + }, [branch, context, onBranchChange]) + + useEffect(() => { + if (!branchMenuOpen) return + const handleClick = (event: MouseEvent) => { + const target = event.target as Node + if (rootRef.current?.contains(target)) return + if (menuRef.current?.contains(target)) return + setBranchMenuOpen(false) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + setBranchMenuOpen(false) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKeyDown) + } + }, [branchMenuOpen]) + + useEffect(() => { + if (!branchMenuOpen) return + updateMenuPos() + window.addEventListener('scroll', updateMenuPos, true) + window.addEventListener('resize', updateMenuPos) + requestAnimationFrame(() => searchRef.current?.focus()) + return () => { + window.removeEventListener('scroll', updateMenuPos, true) + window.removeEventListener('resize', updateMenuPos) + } + }, [branchMenuOpen, updateMenuPos]) + + useEffect(() => { + setSelectedIndex(0) + }, [branchFilter]) + + useEffect(() => { + const activeItem = branchMenuOpen ? itemRefs.current[selectedIndex] : null + activeItem?.scrollIntoView({ block: 'nearest' }) + }, [branchMenuOpen, selectedIndex]) + + const selectedBranch = useMemo(() => { + if (context?.state !== 'ok') return null + return context.branches.find((candidate) => candidate.name === branch) ?? null + }, [branch, context]) + + const filteredBranches = useMemo(() => { + if (context?.state !== 'ok') return [] + const query = branchFilter.trim().toLowerCase() + if (!query) return context.branches + return context.branches.filter((candidate) => ( + candidate.name.toLowerCase().includes(query) || + candidate.remoteRef?.toLowerCase().includes(query) || + candidate.worktreePath?.toLowerCase().includes(query) + )) + }, [branchFilter, context]) + + const warningMessage = useMemo(() => { + if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null + if (selectedBranch.name !== context.currentBranch && context.dirty) { + return t('repoLaunch.dirtyWarning') + } + if (selectedBranch.name !== context.currentBranch && selectedBranch.checkedOut) { + return t('repoLaunch.checkedOutWarning') + } + return null + }, [context, selectedBranch, t, useWorktree]) + + const selectBranch = (candidate: RepositoryBranchInfo) => { + onBranchChange(candidate.name) + setBranchMenuOpen(false) + setBranchFilter('') + } + + const handleBranchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, Math.max(filteredBranches.length - 1, 0))) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + return + } + if (event.key === 'Enter') { + event.preventDefault() + const candidate = filteredBranches[selectedIndex] + if (candidate) selectBranch(candidate) + return + } + if (event.key === 'Escape') { + event.preventDefault() + setBranchMenuOpen(false) + } + } + + const message = stateMessage(context, error) + const isGitReady = context?.state === 'ok' + const isLaunchReady = !workDir || ( + !loading && + (!!context || !!error) && + ( + context?.state !== 'ok' || + context.branches.length === 0 || + !!selectedBranch + ) + ) + + useEffect(() => { + onLaunchReadyChange?.(isLaunchReady) + }, [isLaunchReady, onLaunchReadyChange]) + + return ( +
+
+ + + {loading && workDir && ( +
+ + {t('common.loading')} +
+ )} + + {isGitReady && ( + <> + + + + + )} +
+ + {message && workDir && ( +
+ + + {message === 'not_git' + ? t('repoLaunch.notGit') + : message === 'missing' + ? t('repoLaunch.missingWorkdir') + : message} + +
+ )} + + {warningMessage && ( +
+ + {warningMessage} +
+ )} + + {branchMenuOpen && menuPos && createPortal( +
+
+ +
+ + setBranchFilter(event.target.value)} + onKeyDown={handleBranchKeyDown} + aria-controls={listboxId} + aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined} + placeholder={t('repoLaunch.searchBranch')} + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" + /> +
+
+ +
+ {filteredBranches.length === 0 ? ( +
+ {t('repoLaunch.noBranchMatch')} +
+ ) : filteredBranches.map((candidate, index) => { + const isSelected = candidate.name === selectedBranch?.name + return ( + + ) + })} +
+
, + document.body, + )} +
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fb27d9d6..dfaf9e87 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -673,6 +673,31 @@ export const en = { 'empty.addFiles': 'Add files or photos', 'empty.slashCommands': 'Slash commands', 'empty.failedToCreate': 'Failed to create session', + 'empty.createError.workdirMissing': 'The project folder is missing or is not a directory. Pick an existing project and try again.', + 'empty.createError.notGit': 'This project is not a Git repository, so branch launch is unavailable. Start in the current folder or pick a Git project.', + 'empty.createError.branchNotFound': 'The selected branch no longer exists. Refresh the project context and choose another branch.', + 'empty.createError.dirtyWorktree': 'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.', + 'empty.createError.branchCheckedOut': 'That branch is already checked out in another worktree. Enable "Isolated worktree" or choose a different branch.', + 'empty.createError.worktreeCreateFailed': 'Could not create the isolated worktree. Check that Git can write to this repository, then try again. Detail: {detail}', + 'empty.createError.switchFailed': 'Could not switch the current project branch. Check the Git error, keep your changes safe, then try again. Detail: {detail}', + 'empty.createError.contextFailed': 'Could not inspect this Git project. Check the repository state and try again.', + + // ─── Repository Launch Controls ────────────────────────────────────── + 'repoLaunch.selectBranch': 'Select branch', + 'repoLaunch.searchBranch': 'Search branches', + 'repoLaunch.noBranch': 'No branch', + 'repoLaunch.noBranchMatch': 'No matching branches', + 'repoLaunch.currentBranch': 'Current branch', + 'repoLaunch.localBranch': 'Local branch', + 'repoLaunch.remoteBranch': 'Remote branch', + 'repoLaunch.checkedOut': 'Checked out in another worktree', + 'repoLaunch.worktreeCurrent': 'Current worktree', + 'repoLaunch.worktreeIsolated': 'Isolated worktree', + 'repoLaunch.toggleWorktree': 'Toggle worktree isolation', + '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.', + 'repoLaunch.checkedOutWarning': 'This branch is already checked out elsewhere. Enable isolated worktree or choose another branch.', // ─── Chat Input ────────────────────────────────────── 'chat.placeholder': 'Ask Claude to edit, debug or explain...', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index d6e67a19..37d90367 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -675,6 +675,31 @@ export const zh: Record = { 'empty.addFiles': '添加文件或图片', 'empty.slashCommands': '斜杠命令', 'empty.failedToCreate': '创建会话失败', + 'empty.createError.workdirMissing': '项目目录不存在或不是文件夹。请选择一个存在的项目后重试。', + 'empty.createError.notGit': '当前项目不是 Git 仓库,无法选择分支启动。可以直接在当前目录开始,或改选一个 Git 项目。', + 'empty.createError.branchNotFound': '选中的分支已经不存在。请刷新项目上下文后重新选择分支。', + 'empty.createError.dirtyWorktree': '当前项目有未提交改动,已阻止直接切换分支。请开启“独立工作树”,或先提交/暂存这些改动。', + 'empty.createError.branchCheckedOut': '该分支已在其他工作树中检出。请开启“独立工作树”,或选择其他分支。', + 'empty.createError.worktreeCreateFailed': '无法创建独立工作树。请确认 Git 可以写入此仓库后重试。详情:{detail}', + 'empty.createError.switchFailed': '无法切换当前项目分支。请先确认 Git 错误并保护好本地改动后重试。详情:{detail}', + 'empty.createError.contextFailed': '无法检查这个 Git 项目。请确认仓库状态后重试。', + + // ─── Repository Launch Controls ────────────────────────────────────── + 'repoLaunch.selectBranch': '选择分支', + 'repoLaunch.searchBranch': '搜索分支', + 'repoLaunch.noBranch': '无分支', + 'repoLaunch.noBranchMatch': '没有匹配的分支', + 'repoLaunch.currentBranch': '当前分支', + 'repoLaunch.localBranch': '本地分支', + 'repoLaunch.remoteBranch': '远程分支', + 'repoLaunch.checkedOut': '已在其他工作树中检出', + 'repoLaunch.worktreeCurrent': '当前工作树', + 'repoLaunch.worktreeIsolated': '独立工作树', + 'repoLaunch.toggleWorktree': '切换工作树隔离', + 'repoLaunch.notGit': '当前项目不是 Git 仓库。', + 'repoLaunch.missingWorkdir': '工作目录不存在。', + 'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。', + 'repoLaunch.checkedOutWarning': '这个分支已在其他工作树中检出。请开启独立工作树或选择其他分支。', // ─── Chat Input ────────────────────────────────────── 'chat.placeholder': '让 Claude 编辑、调试或解释代码...', diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index e2a8308c..94f8d461 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -5,6 +5,7 @@ import '@testing-library/jest-dom' const mocks = vi.hoisted(() => ({ createSession: vi.fn(), listSessions: vi.fn(), + getRepositoryContext: vi.fn(), getMessages: vi.fn(), getSlashCommands: vi.fn(), listSkills: vi.fn(), @@ -21,6 +22,7 @@ vi.mock('../api/sessions', () => ({ sessionsApi: { create: mocks.createSession, list: mocks.listSessions, + getRepositoryContext: mocks.getRepositoryContext, getMessages: mocks.getMessages, getSlashCommands: mocks.getSlashCommands, }, @@ -66,12 +68,40 @@ vi.mock('../components/controls/ModelSelector', () => ({ })) import { EmptySession } from './EmptySession' +import { ApiError } from '../api/client' import { useChatStore } from '../stores/chatStore' import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore' import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' import { useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' +import type { RepositoryContextResult } from '../api/sessions' + +function okRepositoryContext(overrides: Partial = {}): RepositoryContextResult { + return { + state: 'ok', + workDir: '/workspace/project', + repoRoot: '/workspace/project', + repoName: 'project', + currentBranch: 'main', + defaultBranch: 'main', + dirty: false, + branches: [{ + name: 'main', + current: true, + local: true, + remote: false, + checkedOut: true, + worktreePath: '/workspace/project', + }], + worktrees: [{ + path: '/workspace/project', + branch: 'main', + current: true, + }], + ...overrides, + } +} describe('EmptySession', () => { const initialSessionState = useSessionStore.getInitialState() @@ -90,6 +120,7 @@ describe('EmptySession', () => { useUIStore.setState(initialUiState, true) mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' }) + mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext()) mocks.listSessions.mockResolvedValue({ sessions: [{ id: 'draft-session', @@ -118,7 +149,7 @@ describe('EmptySession', () => { useUIStore.setState(initialUiState, true) }) - it('creates an empty session as soon as a project is selected', async () => { + it('creates a session with the selected project and branch when submitted', async () => { render() fireEvent.change(screen.getByRole('textbox'), { @@ -126,8 +157,19 @@ describe('EmptySession', () => { }) fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + expect(mocks.createSession).not.toHaveBeenCalled() + await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith('/workspace/project') + expect(screen.getByText('main')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ + workDir: '/workspace/project', + repository: { branch: 'main', worktree: false }, + }) }) expect(useTabStore.getState().activeTabId).toBe('draft-session') @@ -138,7 +180,121 @@ describe('EmptySession', () => { id: 'draft-session', workDir: '/workspace/project', }) - expect(useChatStore.getState().sessions['draft-session']?.composerPrefill?.text).toBe('draft question') + const messages = useChatStore.getState().sessions['draft-session']?.messages ?? [] + expect(messages[messages.length - 1]).toMatchObject({ + type: 'user_text', + content: 'draft question', + }) + expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { + type: 'user_message', + content: 'draft question', + attachments: [], + }) expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session') }) + + it('shows an actionable repository error when direct branch switching is blocked', async () => { + mocks.createSession.mockRejectedValueOnce(new ApiError(400, { + error: 'REPOSITORY_DIRTY_WORKTREE', + message: 'Working tree has uncommitted changes.', + })) + + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'draft question', selectionStart: 14 }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + + await waitFor(() => { + expect(screen.getByText('main')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + + await waitFor(() => { + const toasts = useUIStore.getState().toasts + expect(toasts[toasts.length - 1]?.message).toBe( + 'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.', + ) + }) + expect(useTabStore.getState().activeTabId).toBeNull() + }) + + it('keeps Run disabled until repository context resolves for a selected project', async () => { + let resolveContext: (context: RepositoryContextResult) => void = () => {} + mocks.getRepositoryContext.mockImplementationOnce(() => new Promise((resolve) => { + resolveContext = resolve + })) + + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'draft question', selectionStart: 14 }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Run/i })).toBeDisabled() + }) + + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + expect(mocks.createSession).not.toHaveBeenCalled() + + resolveContext(okRepositoryContext()) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Run/i })).not.toBeDisabled() + }) + }) + + it('falls back to a visible branch when the current branch is an internal desktop worktree branch', async () => { + mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({ + currentBranch: 'worktree-desktop-feature-a-12345678', + defaultBranch: 'main', + branches: [ + { + name: 'main', + current: false, + local: true, + remote: false, + checkedOut: false, + }, + { + name: 'feature/a', + current: false, + local: true, + remote: false, + checkedOut: false, + }, + ], + worktrees: [{ + path: '/workspace/project/.claude/worktrees/desktop-feature-a-12345678', + branch: 'worktree-desktop-feature-a-12345678', + current: true, + }], + })) + + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'draft question', selectionStart: 14 }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + + await waitFor(() => { + expect(screen.getByText('main')).toBeInTheDocument() + }) + + expect(screen.queryByText('worktree-desktop-feature-a-12345678')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ + workDir: '/workspace/project', + repository: { branch: 'main', worktree: false }, + }) + }) + }) }) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 3655d22f..19f100a4 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { ApiError } from '../api/client' import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' @@ -9,7 +10,7 @@ import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' -import { DirectoryPicker } from '../components/shared/DirectoryPicker' +import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' import { AttachmentGallery } from '../components/chat/AttachmentGallery' @@ -24,7 +25,7 @@ import { replaceSlashCommand, resolveSlashUiAction, } from '../components/chat/composerUtils' -import type { AttachmentRef, UIAttachment } from '../types/chat' +import type { AttachmentRef } from '../types/chat' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = { @@ -37,12 +38,52 @@ type Attachment = { data?: string } +type Translate = ReturnType + +function getApiErrorCode(error: unknown): string | null { + if (!(error instanceof ApiError)) return null + const body = error.body + if (!body || typeof body !== 'object' || !('error' in body)) return null + return typeof body.error === 'string' ? body.error : null +} + +function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string { + const code = getApiErrorCode(error) + switch (code) { + case 'WORKDIR_MISSING': + case 'WORKDIR_NOT_DIRECTORY': + return t('empty.createError.workdirMissing') + case 'REPOSITORY_NOT_GIT': + return t('empty.createError.notGit') + case 'REPOSITORY_BRANCH_NOT_FOUND': + return t('empty.createError.branchNotFound') + case 'REPOSITORY_DIRTY_WORKTREE': + return t('empty.createError.dirtyWorktree') + case 'REPOSITORY_BRANCH_CHECKED_OUT': + return t('empty.createError.branchCheckedOut') + case 'REPOSITORY_WORKTREE_CREATE_FAILED': + return t('empty.createError.worktreeCreateFailed', { + detail: error instanceof Error ? error.message : t('empty.failedToCreate'), + }) + case 'REPOSITORY_SWITCH_FAILED': + return t('empty.createError.switchFailed', { + detail: error instanceof Error ? error.message : t('empty.failedToCreate'), + }) + case 'REPOSITORY_CONTEXT_ERROR': + return t('empty.createError.contextFailed') + default: + return error instanceof Error ? error.message : t('empty.failedToCreate') + } +} + export function EmptySession() { const t = useTranslation() const [input, setInput] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const [isCreatingSession, setIsCreatingSession] = useState(false) const [workDir, setWorkDir] = useState('') + const [selectedBranch, setSelectedBranch] = useState(null) + const [useWorktree, setUseWorktree] = useState(false) + const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true) const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) @@ -191,46 +232,11 @@ export function EmptySession() { ) } - const openDraftSessionForWorkDir = async (newWorkDir: string) => { + const handleWorkDirChange = (newWorkDir: string) => { setWorkDir(newWorkDir) - if (!newWorkDir || isCreatingSession || isSubmitting) return - - setIsCreatingSession(true) - try { - const draftSelection = await resolveDraftRuntimeSelection() - const sessionId = await createSession(newWorkDir) - useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) - useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) - setActiveView('code') - useTabStore.getState().openTab(sessionId, 'New Session') - connectToSession(sessionId) - - const draftAttachments: UIAttachment[] = attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.name, - data: attachment.data, - mimeType: attachment.mimeType, - })) - if (input.trim() || draftAttachments.length > 0) { - useChatStore.getState().queueComposerPrefill(sessionId, { - text: input, - attachments: draftAttachments, - }) - } - setInput('') - setAttachments([]) - setFileSearchOpen(false) - setSlashMenuOpen(false) - setPlusMenuOpen(false) - setLocalSlashPanel(null) - } catch (error) { - addToast({ - type: 'error', - message: error instanceof Error ? error.message : t('empty.failedToCreate'), - }) - } finally { - setIsCreatingSession(false) - } + setSelectedBranch(null) + setUseWorktree(false) + setRepositoryLaunchReady(!newWorkDir) } const filteredCommands = useMemo(() => { @@ -248,6 +254,11 @@ export function EmptySession() { if (!normalized) return null return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null }, [filteredCommands, slashFilter]) + const canSubmit = ( + input.trim().length > 0 || + attachments.length > 0 || + !!workDir + ) && !isSubmitting && repositoryLaunchReady useEffect(() => { setSlashSelectedIndex(0) @@ -262,7 +273,7 @@ export function EmptySession() { const handleSubmit = async () => { const text = input.trim() - if ((!text && attachments.length === 0) || isSubmitting) return + if (!canSubmit) return const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null if (slashUiAction?.type === 'panel') { @@ -287,7 +298,12 @@ export function EmptySession() { setIsSubmitting(true) try { const draftSelection = await resolveDraftRuntimeSelection() - const sessionId = await createSession(workDir || undefined) + const sessionId = await createSession( + workDir || undefined, + selectedBranch + ? { repository: { branch: selectedBranch, worktree: useWorktree } } + : undefined, + ) useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) setActiveView('code') @@ -300,13 +316,15 @@ export function EmptySession() { data: attachment.data, mimeType: attachment.mimeType, })) - sendMessage(sessionId, text, attachmentPayload) + if (text || attachmentPayload.length > 0) { + sendMessage(sessionId, text, attachmentPayload) + } setInput('') setAttachments([]) } catch (error) { addToast({ type: 'error', - message: error instanceof Error ? error.message : t('empty.failedToCreate'), + message: resolveCreateSessionErrorMessage(error, t), }) } finally { setIsSubmitting(false) @@ -681,10 +699,10 @@ export function EmptySession() { fallbackModelLabel={draftModelLabel} draft /> - +