diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index acbcae60..1e926440 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -4,6 +4,8 @@ import '@testing-library/jest-dom' const mocks = vi.hoisted(() => ({ getGitInfo: vi.fn(), + getRepositoryContext: vi.fn(), + getRecentProjects: vi.fn(), search: vi.fn(), browse: vi.fn(), wsSend: vi.fn(), @@ -12,6 +14,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../api/sessions', () => ({ sessionsApi: { getGitInfo: mocks.getGitInfo, + getRepositoryContext: mocks.getRepositoryContext, + getRecentProjects: mocks.getRecentProjects, }, })) @@ -47,6 +51,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 +140,52 @@ describe('ChatInput file mentions', () => { }, }) mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 }) + mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext()) + mocks.getRecentProjects.mockResolvedValue({ projects: [] }) + }) + + 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('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..55d01630 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) @@ -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 && !hasMessages + 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)} /> @@ -877,21 +973,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro compact={compact} /> ) : ( - { - 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(() => {}) - }} + )}