fix: expose desktop workspace tools from the start screen

The desktop start screen can already collect a project directory before the first chat turn, but workspace management is intentionally session-scoped. Creating a draft session immediately after project selection keeps the file manager, changed-file view, runtime selection, and terminal toolbar on the same path users see after sending the first message.

Constraint: Workspace file management state is keyed by session id.
Rejected: Add a separate non-session workspace mode | it would duplicate panel state and create a second lifecycle to maintain.
Confidence: high
Scope-risk: narrow
Directive: Keep workspace management session-scoped; create or reuse a session before exposing the workspace panel.
Tested: cd desktop && bun run test -- TabBar.test.tsx EmptySession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: Native Tauri folder dialog manual smoke test.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 17:30:28 +08:00
parent 10b974dac3
commit 3e62c15406
4 changed files with 244 additions and 29 deletions

View File

@ -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: () => <div data-testid="window-controls" />,
showWindowControls: true,
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : 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(<TabBar />)
})
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')

View File

@ -242,8 +242,6 @@ export function TabBar() {
void startDragging().catch(() => {})
}, [])
if (tabs.length === 0 && !showWindowControls) return null
return (
<div
data-testid="tab-bar"

View File

@ -0,0 +1,144 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const mocks = vi.hoisted(() => ({
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 }) => (
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
Pick project
</button>
),
}))
vi.mock('../components/controls/PermissionModeSelector', () => ({
PermissionModeSelector: () => <button type="button">Bypass</button>,
}))
vi.mock('../components/controls/ModelSelector', () => ({
ModelSelector: () => <button type="button">Model</button>,
}))
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(<EmptySession />)
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')
})
})

View File

@ -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<Attachment[]>([])
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() {
</div>
<div className="flex items-center gap-3">
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
<button
onClick={handleSubmit}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting || isCreatingSession}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
>
{t('common.run')}
@ -604,7 +652,7 @@ export function EmptySession() {
</div>
<div>
<DirectoryPicker value={workDir} onChange={setWorkDir} />
<DirectoryPicker value={workDir} onChange={(path) => void openDraftSessionForWorkDir(path)} />
</div>
</div>
</div>