diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index edbe4ed8..1239d821 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -6,6 +6,9 @@ const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve())) const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({ startDragging: startDraggingMock, }))) +const windowControlsMock = vi.hoisted(() => ({ + show: true, +})) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: getCurrentWindowMock, @@ -34,8 +37,10 @@ vi.mock('../../i18n', () => ({ })) vi.mock('./WindowControls', () => ({ - WindowControls: () =>
, - showWindowControls: true, + WindowControls: () => (windowControlsMock.show ?
: null), + get showWindowControls() { + return windowControlsMock.show + }, })) describe('TabBar', () => { @@ -61,6 +66,7 @@ describe('TabBar', () => { startDraggingMock.mockClear() getCurrentWindowMock.mockClear() + windowControlsMock.show = true vi.resetModules() }) @@ -134,6 +140,25 @@ describe('TabBar', () => { expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls')) }) + it('shows the terminal toolbar when no tabs are open', async () => { + windowControlsMock.show = false + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + + useTabStore.setState({ tabs: [], activeTabId: null }) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') + expect(terminalTabs).toHaveLength(1) + expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument() + }) + it('marks the tab bar as a native drag region', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 134b6570..1e92a828 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -242,8 +242,6 @@ export function TabBar() { void startDragging().catch(() => {}) }, []) - if (tabs.length === 0 && !showWindowControls) return null - return (
({ + createSession: vi.fn(), + listSessions: vi.fn(), + getMessages: vi.fn(), + getSlashCommands: vi.fn(), + listSkills: vi.fn(), + getTasksForList: vi.fn(), + resetTaskList: vi.fn(), + wsClearHandlers: vi.fn(), + wsConnect: vi.fn(), + wsOnMessage: vi.fn(), + wsSend: vi.fn(), + wsDisconnect: vi.fn(), +})) + +vi.mock('../api/sessions', () => ({ + sessionsApi: { + create: mocks.createSession, + list: mocks.listSessions, + getMessages: mocks.getMessages, + getSlashCommands: mocks.getSlashCommands, + }, +})) + +vi.mock('../api/skills', () => ({ + skillsApi: { + list: mocks.listSkills, + }, +})) + +vi.mock('../api/cliTasks', () => ({ + cliTasksApi: { + getTasksForList: mocks.getTasksForList, + resetTaskList: mocks.resetTaskList, + }, +})) + +vi.mock('../api/websocket', () => ({ + wsManager: { + clearHandlers: mocks.wsClearHandlers, + connect: mocks.wsConnect, + onMessage: mocks.wsOnMessage, + send: mocks.wsSend, + disconnect: mocks.wsDisconnect, + }, +})) + +vi.mock('../components/shared/DirectoryPicker', () => ({ + DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => ( + + ), +})) + +vi.mock('../components/controls/PermissionModeSelector', () => ({ + PermissionModeSelector: () => , +})) + +vi.mock('../components/controls/ModelSelector', () => ({ + ModelSelector: () => , +})) + +import { EmptySession } from './EmptySession' +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' + +describe('EmptySession', () => { + const initialSessionState = useSessionStore.getInitialState() + const initialChatState = useChatStore.getInitialState() + const initialTabState = useTabStore.getInitialState() + const initialRuntimeState = useSessionRuntimeStore.getInitialState() + const initialUiState = useUIStore.getInitialState() + + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en', activeProviderName: null }) + useSessionStore.setState(initialSessionState, true) + useChatStore.setState(initialChatState, true) + useTabStore.setState(initialTabState, true) + useSessionRuntimeStore.setState(initialRuntimeState, true) + useUIStore.setState(initialUiState, true) + + mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' }) + mocks.listSessions.mockResolvedValue({ + sessions: [{ + id: 'draft-session', + title: 'New Session', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + total: 1, + }) + mocks.getMessages.mockResolvedValue({ messages: [] }) + mocks.getSlashCommands.mockResolvedValue({ commands: [] }) + mocks.listSkills.mockResolvedValue({ skills: [] }) + mocks.getTasksForList.mockResolvedValue({ tasks: [] }) + mocks.resetTaskList.mockResolvedValue(undefined) + }) + + afterEach(() => { + useSessionStore.setState(initialSessionState, true) + useChatStore.setState(initialChatState, true) + useTabStore.setState(initialTabState, true) + useSessionRuntimeStore.setState(initialRuntimeState, true) + useUIStore.setState(initialUiState, true) + }) + + it('creates an empty session as soon as a project is selected', async () => { + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'draft question', selectionStart: 14 }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith('/workspace/project') + }) + + expect(useTabStore.getState().activeTabId).toBe('draft-session') + expect(useTabStore.getState().tabs).toEqual([ + { sessionId: 'draft-session', title: 'New Session', type: 'session', status: 'idle' }, + ]) + expect(useSessionStore.getState().sessions[0]).toMatchObject({ + id: 'draft-session', + workDir: '/workspace/project', + }) + expect(useChatStore.getState().sessions['draft-session']?.composerPrefill?.text).toBe('draft question') + expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session') + }) +}) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 34c0f065..696e26e1 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -23,7 +23,7 @@ import { replaceSlashCommand, resolveSlashUiAction, } from '../components/chat/composerUtils' -import type { AttachmentRef } from '../types/chat' +import type { AttachmentRef, UIAttachment } from '../types/chat' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = { @@ -39,6 +39,7 @@ 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 [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) @@ -157,6 +158,73 @@ export function EmptySession() { [slashCommands], ) + const resolveDraftRuntimeSelection = async () => { + const settings = useSettingsStore.getState() + let providerState = useProviderStore.getState() + if ( + settings.activeProviderName && + providerState.providers.length === 0 && + !providerState.isLoading + ) { + await providerState.fetchProviders() + providerState = useProviderStore.getState() + } + const inferredProviderId = providerState.activeId ?? ( + settings.activeProviderName + ? providerState.providers.find((provider) => provider.name === settings.activeProviderName)?.id ?? null + : null + ) + return ( + useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] + ?? { + providerId: inferredProviderId, + modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID, + } + ) + } + + const openDraftSessionForWorkDir = async (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) + } + } + const filteredCommands = useMemo(() => { const source = allSlashCommands if (!slashFilter) return source @@ -210,27 +278,7 @@ export function EmptySession() { setIsSubmitting(true) try { - const settings = useSettingsStore.getState() - let providerState = useProviderStore.getState() - if ( - settings.activeProviderName && - providerState.providers.length === 0 && - !providerState.isLoading - ) { - await providerState.fetchProviders() - providerState = useProviderStore.getState() - } - const inferredProviderId = providerState.activeId ?? ( - settings.activeProviderName - ? providerState.providers.find((provider) => provider.name === settings.activeProviderName)?.id ?? null - : null - ) - const draftSelection = - useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] - ?? { - providerId: inferredProviderId, - modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID, - } + const draftSelection = await resolveDraftRuntimeSelection() const sessionId = await createSession(workDir || undefined) useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) @@ -590,10 +638,10 @@ export function EmptySession() {
- +
- + void openDraftSessionForWorkDir(path)} />