+
{/* Trigger — shows selected project chip or placeholder */}
{value ? (
) : (
)}
@@ -283,16 +296,22 @@ export function DirectoryPicker({ value, onChange }: Props) {
{browseEntries.length === 0 ? (
{t('dirPicker.noSubdirs')}
) : browseEntries.map((entry) => (
-
))}
>
)}
diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx
index 45fb8285..d2da78af 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])
@@ -199,12 +234,30 @@ export function RepositoryLaunchControls({
return null
}, [context, selectedBranch, t, useWorktree])
+ const requiresIsolation = useMemo(() => {
+ if (context?.state !== 'ok' || !selectedBranch) return false
+ if (selectedBranch.name === context.currentBranch) return false
+ return context.dirty || selectedBranch.checkedOut
+ }, [context, selectedBranch])
+
+ useEffect(() => {
+ if (requiresIsolation && !useWorktree) {
+ onUseWorktreeChange(true)
+ }
+ }, [onUseWorktreeChange, requiresIsolation, useWorktree])
+
const selectBranch = (candidate: RepositoryBranchInfo) => {
onBranchChange(candidate.name)
setBranchMenuOpen(false)
setBranchFilter('')
}
+ const selectWorktreeMode = (enabled: boolean) => {
+ if (!enabled && requiresIsolation) return
+ onUseWorktreeChange(enabled)
+ setWorktreeMenuOpen(false)
+ }
+
const handleBranchKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
@@ -244,13 +297,16 @@ export function RepositoryLaunchControls({
onLaunchReadyChange?.(isLaunchReady)
}, [isLaunchReady, onLaunchReadyChange])
+ const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')
+ const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50'
+
return (
-
-
+
+
{loading && workDir && (
-
+
{t('common.loading')}
@@ -258,42 +314,53 @@ export function RepositoryLaunchControls({
{isGitReady && (
<>
+
{
setBranchMenuOpen((prev) => !prev)
+ setWorktreeMenuOpen(false)
setBranchFilter('')
}}
- className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
+ className={`${workbarButtonClassName} max-w-[260px] shrink`}
>
-
-
+
+
{selectedBranch?.name || t('repoLaunch.noBranch')}
-
+
onUseWorktreeChange(!useWorktree)}
- className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50 ${
+ aria-haspopup="listbox"
+ aria-expanded={worktreeMenuOpen}
+ aria-controls={worktreeMenuOpen ? worktreeListboxId : undefined}
+ aria-label={`${t('repoLaunch.selectWorktree')}: ${worktreeLabel}`}
+ title={worktreeLabel}
+ onClick={() => {
+ setWorktreeMenuOpen((prev) => !prev)
+ setBranchMenuOpen(false)
+ }}
+ className={`${workbarButtonClassName} shrink-0 ${
useWorktree
- ? 'border-[var(--color-brand)]/40 bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
- : 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
+ ? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
+ : ''
}`}
>
-
-
- {useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')}
+
+
+ {worktreeLabel}
+
>
)}
@@ -397,6 +464,62 @@ export function RepositoryLaunchControls({
,
document.body,
)}
+
+ {worktreeMenuOpen && worktreeMenuPos && createPortal(
+
+
+ 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)]'
+ }`}
+ >
+
+
+
+ {t('repoLaunch.worktreeCurrent')}
+
+
+ {!useWorktree && }
+
+
+ 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)]'
+ }`}
+ >
+
+
+
+ {t('repoLaunch.worktreeIsolated')}
+
+
+ {useWorktree && }
+
+
+
,
+ 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/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx
index 8ea7357b..ad0e23c3 100644
--- a/desktop/src/pages/ActiveSession.test.tsx
+++ b/desktop/src/pages/ActiveSession.test.tsx
@@ -75,6 +75,57 @@ afterEach(() => {
})
describe('ActiveSession task polling', () => {
+ it('treats a persisted historical session as non-empty before messages finish loading', () => {
+ const sessionId = 'history-loading-session'
+
+ useSessionStore.setState({
+ sessions: [{
+ id: sessionId,
+ title: 'History Loading Session',
+ createdAt: '2026-05-07T00:00:00.000Z',
+ modifiedAt: '2026-05-07T00:00:00.000Z',
+ messageCount: 2,
+ projectPath: '/workspace/project',
+ workDir: '/workspace/project',
+ workDirExists: true,
+ }],
+ activeSessionId: sessionId,
+ isLoading: false,
+ error: null,
+ })
+ useTabStore.setState({
+ tabs: [{ sessionId, title: 'History Loading Session', type: 'session', status: 'idle' }],
+ activeTabId: sessionId,
+ })
+ useChatStore.setState({
+ sessions: {
+ [sessionId]: {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ agentTaskNotifications: {},
+ elapsedTimer: null,
+ },
+ },
+ })
+
+ render()
+
+ expect(screen.getByTestId('message-list')).toBeInTheDocument()
+ expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
+ })
+
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
vi.useFakeTimers()
diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx
index 8ceb5fb1..83ec02be 100644
--- a/desktop/src/pages/ActiveSession.tsx
+++ b/desktop/src/pages/ActiveSession.tsx
@@ -260,7 +260,7 @@ export function ActiveSession() {
const t = useTranslation()
const messages = sessionState?.messages ?? []
const streamingText = sessionState?.streamingText ?? ''
- const isEmpty = messages.length === 0 && !streamingText
+ const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const isActive = chatState !== 'idle'
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx
index 2c515703..52fdcf34 100644
--- a/desktop/src/pages/EmptySession.test.tsx
+++ b/desktop/src/pages/EmptySession.test.tsx
@@ -303,4 +303,93 @@ describe('EmptySession', () => {
})
})
})
+
+ it('keeps the repository launch context on one row and truncates long branch names', async () => {
+ const longBranch = 'feature/super-long-branch-name-for-repository-launch-controls-e2e'
+ mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
+ currentBranch: longBranch,
+ defaultBranch: 'main',
+ branches: [{
+ name: longBranch,
+ current: true,
+ local: true,
+ remote: false,
+ checkedOut: true,
+ worktreePath: '/workspace/project',
+ }],
+ worktrees: [{
+ path: '/workspace/project',
+ branch: longBranch,
+ current: true,
+ }],
+ }))
+
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), {
+ target: { value: 'draft question', selectionStart: 14 },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
+
+ const branchButton = await screen.findByRole('button', { name: new RegExp(`Select branch: ${longBranch}`) })
+ const branchClasses = branchButton.className.split(/\s+/)
+ expect(branchButton.parentElement?.className).toContain('flex-nowrap')
+ expect(branchButton.parentElement?.className).not.toContain('flex-wrap')
+ expect(branchClasses).toContain('max-w-[260px]')
+ expect(branchClasses).not.toContain('max-w-full')
+ expect(branchButton.querySelector('span')?.className).toContain('truncate')
+ })
+
+ it('defaults to isolated worktree when the fallback branch is checked out elsewhere', async () => {
+ mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
+ currentBranch: null,
+ defaultBranch: 'main',
+ branches: [
+ {
+ name: 'main',
+ current: false,
+ local: true,
+ remote: false,
+ checkedOut: true,
+ worktreePath: '/workspace/project',
+ },
+ {
+ name: 'feature/a',
+ current: false,
+ local: true,
+ remote: false,
+ checkedOut: false,
+ },
+ ],
+ worktrees: [{
+ path: '/workspace/project/.codex/worktrees/detached/project',
+ branch: null,
+ 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.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(() => {
+ expect(mocks.createSession).toHaveBeenCalledWith({
+ workDir: '/workspace/project',
+ repository: { branch: 'main', worktree: true },
+ })
+ })
+ })
})
diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx
index 19f100a4..60e1f179 100644
--- a/desktop/src/pages/EmptySession.tsx
+++ b/desktop/src/pages/EmptySession.tsx
@@ -546,9 +546,9 @@ export function EmptySession() {
-
+
event.preventDefault()}
onDrop={handleDrop}
>