mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +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(() => ({
|
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
|
||||||
startDragging: startDraggingMock,
|
startDragging: startDraggingMock,
|
||||||
})))
|
})))
|
||||||
|
const windowControlsMock = vi.hoisted(() => ({
|
||||||
|
show: true,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/window', () => ({
|
vi.mock('@tauri-apps/api/window', () => ({
|
||||||
getCurrentWindow: getCurrentWindowMock,
|
getCurrentWindow: getCurrentWindowMock,
|
||||||
@ -34,8 +37,10 @@ vi.mock('../../i18n', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./WindowControls', () => ({
|
vi.mock('./WindowControls', () => ({
|
||||||
WindowControls: () => <div data-testid="window-controls" />,
|
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : null),
|
||||||
showWindowControls: true,
|
get showWindowControls() {
|
||||||
|
return windowControlsMock.show
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
@ -61,6 +66,7 @@ describe('TabBar', () => {
|
|||||||
|
|
||||||
startDraggingMock.mockClear()
|
startDraggingMock.mockClear()
|
||||||
getCurrentWindowMock.mockClear()
|
getCurrentWindowMock.mockClear()
|
||||||
|
windowControlsMock.show = true
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -134,6 +140,25 @@ describe('TabBar', () => {
|
|||||||
expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls'))
|
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 () => {
|
it('marks the tab bar as a native drag region', async () => {
|
||||||
const { TabBar } = await import('./TabBar')
|
const { TabBar } = await import('./TabBar')
|
||||||
const { useTabStore } = await import('../../stores/tabStore')
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
|
|||||||
@ -242,8 +242,6 @@ export function TabBar() {
|
|||||||
void startDragging().catch(() => {})
|
void startDragging().catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (tabs.length === 0 && !showWindowControls) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="tab-bar"
|
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,
|
replaceSlashCommand,
|
||||||
resolveSlashUiAction,
|
resolveSlashUiAction,
|
||||||
} from '../components/chat/composerUtils'
|
} from '../components/chat/composerUtils'
|
||||||
import type { AttachmentRef } from '../types/chat'
|
import type { AttachmentRef, UIAttachment } from '../types/chat'
|
||||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
@ -39,6 +39,7 @@ export function EmptySession() {
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isCreatingSession, setIsCreatingSession] = useState(false)
|
||||||
const [workDir, setWorkDir] = useState('')
|
const [workDir, setWorkDir] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||||
@ -157,6 +158,73 @@ export function EmptySession() {
|
|||||||
[slashCommands],
|
[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 filteredCommands = useMemo(() => {
|
||||||
const source = allSlashCommands
|
const source = allSlashCommands
|
||||||
if (!slashFilter) return source
|
if (!slashFilter) return source
|
||||||
@ -210,27 +278,7 @@ export function EmptySession() {
|
|||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const settings = useSettingsStore.getState()
|
const draftSelection = await resolveDraftRuntimeSelection()
|
||||||
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 sessionId = await createSession(workDir || undefined)
|
const sessionId = await createSession(workDir || undefined)
|
||||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
||||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||||
@ -590,10 +638,10 @@ export function EmptySession() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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')}
|
{t('common.run')}
|
||||||
@ -604,7 +652,7 @@ export function EmptySession() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DirectoryPicker value={workDir} onChange={setWorkDir} />
|
<DirectoryPicker value={workDir} onChange={(path) => void openDraftSessionForWorkDir(path)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user