mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
10b974dac3
commit
3e62c15406
@ -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')
|
||||
|
||||
@ -242,8 +242,6 @@ export function TabBar() {
|
||||
void startDragging().catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (tabs.length === 0 && !showWindowControls) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="tab-bar"
|
||||
|
||||
144
desktop/src/pages/EmptySession.test.tsx
Normal file
144
desktop/src/pages/EmptySession.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user