diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index acbcae60..cdba591d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -3,7 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' const mocks = vi.hoisted(() => ({ + create: vi.fn(), + delete: vi.fn(), + list: vi.fn(), + getMessages: vi.fn(), getGitInfo: vi.fn(), + getSlashCommands: vi.fn(), + getRepositoryContext: vi.fn(), + getRecentProjects: vi.fn(), search: vi.fn(), browse: vi.fn(), wsSend: vi.fn(), @@ -11,7 +18,14 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../api/sessions', () => ({ sessionsApi: { + create: mocks.create, + delete: mocks.delete, + list: mocks.list, + getMessages: mocks.getMessages, getGitInfo: mocks.getGitInfo, + getSlashCommands: mocks.getSlashCommands, + getRepositoryContext: mocks.getRepositoryContext, + getRecentProjects: mocks.getRecentProjects, }, })) @@ -47,6 +61,40 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +function okRepositoryContext() { + return { + state: 'ok' as const, + workDir: '/repo', + repoRoot: '/repo', + repoName: 'repo', + currentBranch: 'main', + defaultBranch: 'main', + dirty: false, + branches: [ + { + name: 'main', + current: true, + local: true, + remote: false, + checkedOut: true, + worktreePath: '/repo', + }, + { + name: 'feature/a', + current: false, + local: true, + remote: false, + checkedOut: false, + }, + ], + worktrees: [{ + path: '/repo', + branch: 'main', + current: true, + }], + } +} + describe('ChatInput file mentions', () => { const sessionId = 'session-file-mention' const initialChatState = useChatStore.getInitialState() @@ -102,6 +150,231 @@ describe('ChatInput file mentions', () => { }, }) mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 }) + mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext()) + mocks.getRecentProjects.mockResolvedValue({ projects: [] }) + mocks.create.mockResolvedValue({ sessionId: 'created-session', workDir: '/repo' }) + mocks.delete.mockResolvedValue({ ok: true }) + mocks.list.mockResolvedValue({ sessions: [], total: 0 }) + mocks.getMessages.mockResolvedValue({ messages: [] }) + mocks.getSlashCommands.mockResolvedValue({ commands: [] }) + }) + + it('shows branch and worktree launch controls for an empty active Git session', async () => { + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: 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(await screen.findByRole('button', { name: /Select branch: main/ })).toBeInTheDocument() + expect(screen.getByText('Current worktree')).toBeInTheDocument() + expect(screen.queryByText('Select a project...')).not.toBeInTheDocument() + }) + + it('uses the persisted message count to keep reopened sessions in context mode while history loads', async () => { + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 2, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: 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(await screen.findByText('repo')).toBeInTheDocument() + expect(screen.getByText('main')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Select branch:/ })).not.toBeInTheDocument() + expect(screen.queryByText('Current worktree')).not.toBeInTheDocument() + }) + + it('starts an empty active session on the selected branch without an isolated worktree', async () => { + mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' }) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: 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() + + fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ })) + fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ })) + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { target: { value: 'run on feature branch', selectionStart: 21 } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => { + expect(mocks.create).toHaveBeenCalledWith({ + workDir: '/repo', + repository: { branch: 'feature/a', worktree: false }, + }) + }) + expect(mocks.delete).toHaveBeenCalledWith(sessionId) + expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', { + type: 'user_message', + content: 'run on feature branch', + attachments: [], + }) + }) + + it('starts an empty active session on the selected branch inside an isolated worktree', async () => { + mocks.create.mockResolvedValueOnce({ + sessionId: 'created-worktree', + workDir: '/repo/.claude/worktrees/desktop-feature-a-12345678', + }) + mocks.list.mockImplementationOnce(() => new Promise(() => {})) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: 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() + + 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: /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 } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => { + expect(mocks.create).toHaveBeenCalledWith({ + workDir: '/repo', + repository: { branch: 'feature/a', worktree: true }, + }) + }) + expect(mocks.delete).toHaveBeenCalledWith(sessionId) + expect(mocks.wsSend).toHaveBeenCalledWith('created-worktree', { + type: 'user_message', + content: 'run in a worktree', + attachments: [], + }) + expect(useSessionStore.getState().sessions[0]?.workDir) + .toBe('/repo/.claude/worktrees/desktop-feature-a-12345678') }) it('turns a selected @ file into a chip without corrupting the typed path', async () => { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2030c315..2f7945ac 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -18,7 +18,7 @@ import { ModelSelector } from '../controls/ModelSelector' import type { AttachmentRef } from '../../types/chat' import { AttachmentGallery } from './AttachmentGallery' import { ProjectContextChip } from '../shared/ProjectContextChip' -import { DirectoryPicker } from '../shared/DirectoryPicker' +import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { ContextUsageIndicator } from './ContextUsageIndicator' @@ -78,6 +78,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) + const [launchWorkDir, setLaunchWorkDir] = useState('') + const [launchBranch, setLaunchBranch] = useState(null) + const [launchUseWorktree, setLaunchUseWorktree] = useState(false) + const [launchReady, setLaunchReady] = useState(true) + const [launchTransitioning, setLaunchTransitioning] = useState(false) const composingRef = useRef(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) @@ -91,7 +96,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] const composerPrefill = sessionState?.composerPrefill ?? null - const messageCount = sessionState?.messages?.length ?? 0 const runtimeSelection = useSessionRuntimeStore((state) => activeTabId ? state.selections[activeTabId] : undefined, ) @@ -101,9 +105,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro : undefined const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) + const loadedMessageCount = sessionState?.messages?.length ?? 0 + const messageCount = Math.max(loadedMessageCount, activeSession?.messageCount ?? 0) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) - const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) const workspaceReferences = useWorkspaceChatContextStore( (s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES, ) @@ -115,9 +120,17 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0 - const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined + const showLaunchControls = !isMemberSession && messageCount === 0 + const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') + const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/') + ? resolveSlashUiAction(input.trim().slice(1)) + : null + const canSubmit = !isWorkspaceMissing && + !launchTransitioning && + (!showLaunchControls || launchReady || !!pendingSlashUiAction) && + (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) const composerAttachments = useMemo( () => [ ...attachments, @@ -181,6 +194,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro setFileSearchOpen(false) }, [isMemberSession, activeTabId]) + useEffect(() => { + if (!showLaunchControls) return + const nextWorkDir = activeSession?.workDir || gitInfo?.workDir || '' + setLaunchWorkDir((current) => { + if (current === nextWorkDir) return current + setLaunchBranch(null) + setLaunchUseWorktree(false) + setLaunchReady(!nextWorkDir) + return nextWorkDir + }) + }, [activeSession?.workDir, activeTabId, gitInfo?.workDir, showLaunchControls]) + useEffect(() => { const el = textareaRef.current if (!el) return @@ -351,13 +376,53 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro }) }, [input]) - const handleSubmit = () => { + const replaceEmptySession = useCallback(async ( + workDir: string, + repository?: { branch?: string | null; worktree?: boolean }, + ) => { + if (!activeTabId) return null + const oldId = activeTabId + const { createSession, deleteSession } = useSessionStore.getState() + const { replaceTabSession } = useTabStore.getState() + const { disconnectSession, connectToSession } = useChatStore.getState() + const newId = await createSession( + workDir || undefined, + repository ? { repository } : undefined, + ) + useSessionRuntimeStore.getState().moveSelection(oldId, newId) + disconnectSession(oldId) + replaceTabSession(oldId, newId) + connectToSession(newId) + deleteSession(oldId).catch(() => {}) + return newId + }, [activeTabId]) + + const handleLaunchWorkDirChange = useCallback(async (newWorkDir: string) => { + setLaunchWorkDir(newWorkDir) + setLaunchBranch(null) + setLaunchUseWorktree(false) + setLaunchReady(!newWorkDir) + if (!activeTabId) return + + setLaunchTransitioning(true) + try { + await replaceEmptySession(newWorkDir) + } catch (error) { + useUIStore.getState().addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('empty.failedToCreate'), + }) + } finally { + setLaunchTransitioning(false) + } + }, [activeTabId, replaceEmptySession, t]) + + const handleSubmit = async () => { const text = input.trim() if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return - const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null - if (slashUiAction?.type === 'panel') { - setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName) + if (pendingSlashUiAction?.type === 'panel') { + setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName) setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) @@ -365,8 +430,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return } - if (slashUiAction?.type === 'settings') { - useUIStore.getState().setPendingSettingsTab(slashUiAction.tab) + if (pendingSlashUiAction?.type === 'settings') { + useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') setInput('') setSlashMenuOpen(false) @@ -375,6 +440,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return } + if (showLaunchControls && (!launchReady || launchTransitioning)) return + const workspaceReferencePrompt = !isMemberSession ? formatWorkspaceReferencePrompt(workspaceReferences) : '' @@ -417,13 +484,42 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro })), ] - sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { + let targetSessionId = activeTabId! + if (showLaunchControls && activeLaunchWorkDir && launchBranch) { + const shouldReplaceForRepositoryLaunch = + launchUseWorktree || + (gitInfo?.branch ? launchBranch !== gitInfo.branch : true) + if (shouldReplaceForRepositoryLaunch) { + setLaunchTransitioning(true) + try { + const newSessionId = await replaceEmptySession(activeLaunchWorkDir, { + branch: launchBranch, + worktree: launchUseWorktree, + }) + if (!newSessionId) return + targetSessionId = newSessionId + } catch (error) { + useUIStore.getState().addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('empty.failedToCreate'), + }) + return + } finally { + setLaunchTransitioning(false) + } + } + } + + sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { displayContent, displayAttachments: visibleAttachmentPayload, }) setInput('') setAttachments([]) - if (!isMemberSession) clearWorkspaceReferences(activeTabId!) + if (!isMemberSession) { + clearWorkspaceReferences(activeTabId!) + if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId) + } setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) @@ -620,7 +716,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{ if (atCursorPos < 0) return @@ -688,7 +784,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro setLocalSlashPanel(null)} /> @@ -869,7 +965,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro {!isMemberSession && (
- {hasMessages ? ( + {messageCount > 0 ? ( ) : ( - { - if (!activeTabId) return - const oldId = activeTabId - const { deleteSession, createSession } = useSessionStore.getState() - const { replaceTabSession } = useTabStore.getState() - const { disconnectSession, connectToSession } = useChatStore.getState() - const newId = await createSession(newWorkDir) - useSessionRuntimeStore.getState().moveSelection(oldId, newId) - disconnectSession(oldId) - replaceTabSession(oldId, newId) - connectToSession(newId) - deleteSession(oldId).catch(() => {}) - }} + )}
diff --git a/desktop/src/components/shared/DirectoryPicker.test.tsx b/desktop/src/components/shared/DirectoryPicker.test.tsx index fa638391..75b2d2a2 100644 --- a/desktop/src/components/shared/DirectoryPicker.test.tsx +++ b/desktop/src/components/shared/DirectoryPicker.test.tsx @@ -16,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({ import { DirectoryPicker } from './DirectoryPicker' import { sessionsApi } from '../../api/sessions' +import { filesystemApi } from '../../api/filesystem' describe('DirectoryPicker', () => { it('uses the source repository name as the fallback label for desktop worktree paths', () => { @@ -57,4 +58,74 @@ describe('DirectoryPicker', () => { expect(trigger).toHaveTextContent('NanmiCoder/OpenCutSkill') expect(trigger).not.toHaveTextContent('main') }) + + it('supports the flat workbar trigger variant without changing the selected label', () => { + render( + , + ) + + const trigger = screen.getByRole('button') + expect(trigger).toHaveTextContent('project') + expect(trigger.className).toContain('rounded-[7px]') + expect(trigger.className).not.toContain('rounded-full') + }) + + it('constrains long workbar project names without hiding the full path from hover users', () => { + const longProjectName = 'project-with-a-very-long-directory-name-that-should-not-stretch-the-launch-bar' + const longPath = `/workspace/${longProjectName}` + + render( + , + ) + + const trigger = screen.getByRole('button') + const label = screen.getByText(longProjectName) + const triggerClasses = trigger.className.split(/\s+/) + expect(trigger).toHaveAttribute('title', longPath) + expect(triggerClasses).toContain('max-w-full') + expect(triggerClasses).not.toContain('w-full') + expect(trigger.parentElement?.className).toContain('max-w-[320px]') + expect(label.className).toContain('truncate') + }) + + it('can show a Git icon for workbar projects before the recent-project cache is loaded', () => { + render( + , + ) + + expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument() + }) + + it('renders browse entries without nesting interactive buttons', async () => { + vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] }) + vi.mocked(filesystemApi.browse).mockResolvedValue({ + currentPath: '/workspace', + parentPath: '/Users/nanmi', + entries: [{ name: 'project', path: '/workspace/project', isDirectory: true }], + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + fireEvent.click(screen.getByRole('button', { name: /选择项目|Select a project/ })) + fireEvent.click(await screen.findByText(/选择其他文件夹|Choose a different folder/)) + + expect(await screen.findByRole('button', { name: /project/ })).toBeInTheDocument() + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('validateDOMNesting')) + + errorSpy.mockRestore() + }) }) diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 1b68bc81..f98d5e0c 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -7,6 +7,8 @@ import { useTranslation } from '../../i18n' type Props = { value: string onChange: (path: string) => void + variant?: 'chip' | 'workbar' + isGitProject?: boolean } type DirEntry = { name: string; path: string; isDirectory: boolean } @@ -28,7 +30,7 @@ function projectNameFromPath(filePath: string) { return displayRoot.split('/').filter(Boolean).pop() || filePath } -export function DirectoryPicker({ value, onChange }: Props) { +export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProject = false }: Props) { const t = useTranslation() const [isOpen, setIsOpen] = useState(false) const [mode, setMode] = useState<'recent' | 'browse'>('recent') @@ -144,36 +146,47 @@ export function DirectoryPicker({ value, onChange }: Props) { // Find selected project info const selectedProject = projects.find((p) => p.realPath === value) + const isWorkbar = variant === 'workbar' + const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value) + const showGitIcon = selectedProject?.isGit || isGitProject + const triggerClassName = isWorkbar + ? 'inline-flex h-9 max-w-full 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' + : 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]' + const emptyTriggerClassName = isWorkbar + ? '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' + : 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors' return ( -
+
{/* 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 && ( <> +
, 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/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} >