Files
claude-code-haha/desktop/src/pages/EmptySession.test.tsx
T
程序员阿江-Relakkes 50f784114b feat(desktop): start H5 phone tasks from a new-task sheet (#1461)
The phone home is now the session list alone. The docked composer took a
quarter of the screen and could not send until a project was picked, so a
New task button floats over the list instead and opens a full-height sheet.

- The sheet puts the project at the top, above the keyboard, and docks the
  composer to the bottom. Cancel, the system back gesture or pulling the bar
  down closes it; sending swaps it for the new session.
- The project defaults to the list's project filter, otherwise to the
  project of the most recently created task.
- On a tablet the button opens the new-session page beside the list.
2026-10-06 04:09:05 +08:00

1725 lines
62 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const originalRangeGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
const mocks = vi.hoisted(() => ({
createSession: vi.fn(),
listSessions: vi.fn(),
getRepositoryContext: vi.fn(),
getMessages: vi.fn(),
getSlashCommands: vi.fn(),
listSkills: vi.fn(),
listAgents: vi.fn(),
listReferences: vi.fn(),
search: vi.fn(),
browse: vi.fn(),
getTasksForList: vi.fn(),
resetTaskList: vi.fn(),
getProviderAuthStatus: vi.fn(),
wsClearHandlers: vi.fn(),
wsConnect: vi.fn(),
wsOnMessage: vi.fn(),
wsSend: vi.fn(),
wsDisconnect: vi.fn(),
dialogOpen: vi.fn(),
webviewDragHandlers: [] as Array<(event: { payload: unknown }) => void>,
webviewUnlisten: vi.fn(),
isMobile: false,
isTauriRuntime: false,
voiceSupported: vi.fn(() => false),
voiceStartRecording: vi.fn(),
voiceTranscribe: vi.fn(),
voiceCatalog: vi.fn(async () => ({
supported: false,
providers: [],
preferences: { enabled: false, providerId: 'sensevoice-local', language: 'auto', downloadSource: 'auto' },
limits: { maxAudioSeconds: 60, maxAudioBytes: 1_000_000 },
})),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
create: mocks.createSession,
list: mocks.listSessions,
getRepositoryContext: mocks.getRepositoryContext,
getMessages: mocks.getMessages,
getSlashCommands: mocks.getSlashCommands,
},
}))
vi.mock('@/api/voice', () => ({
voiceApi: {
catalog: mocks.voiceCatalog,
transcribe: mocks.voiceTranscribe,
providerStatus: vi.fn(),
updatePreferences: vi.fn(),
prepare: vi.fn(),
cancelPrepare: vi.fn(),
removeAssets: vi.fn(),
},
}))
vi.mock('@/features/voiceInput/recorder', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/voiceInput/recorder')>()),
isVoiceCaptureSupported: mocks.voiceSupported,
startRecording: mocks.voiceStartRecording,
}))
vi.mock('../api/skills', () => ({
skillsApi: {
list: mocks.listSkills,
},
}))
vi.mock('../api/composerReferences', async (importOriginal) => ({
...await importOriginal<typeof import('../api/composerReferences')>(),
composerReferencesApi: { list: mocks.listReferences },
}))
vi.mock('../api/agents', () => ({
agentsApi: {
list: mocks.listAgents,
},
}))
vi.mock('../api/providers', () => ({
providersApi: {
authStatus: mocks.getProviderAuthStatus,
},
}))
vi.mock('../api/filesystem', () => ({
filesystemApi: {
search: mocks.search,
browse: mocks.browse,
},
}))
vi.mock('../api/cliTasks', () => ({
cliTasksApi: {
getTasksForList: mocks.getTasksForList,
resetTaskList: mocks.resetTaskList,
},
}))
vi.mock('../api/websocket', () => ({
wsManager: {
clearHandlers: mocks.wsClearHandlers,
connect: mocks.wsConnect,
onConnectionState: vi.fn((_sessionId: string, handler: (state: string) => void) => {
handler('connecting')
return () => {}
}),
onMessage: mocks.wsOnMessage,
send: mocks.wsSend,
disconnect: mocks.wsDisconnect,
},
}))
vi.mock('../hooks/useMobileViewport', () => ({
useMobileViewport: () => mocks.isMobile,
}))
vi.mock('../lib/desktopRuntime', () => ({
isTauriRuntime: () => mocks.isTauriRuntime,
isDesktopRuntime: () => mocks.isTauriRuntime,
}))
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: mocks.dialogOpen,
}))
vi.mock('@tauri-apps/api/webview', () => ({
getCurrentWebview: () => ({
onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => {
mocks.webviewDragHandlers.push(handler)
return mocks.webviewUnlisten
}),
}),
}))
vi.mock('@/components/composite/DirectoryPicker', () => ({
RecentProjectsPanel: ({ value, onSelect }: { value: string; onSelect: (path: string) => void }) => (
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onSelect('/workspace/project')}>
Pick project
</button>
),
}))
// The launch controls' project editor is out of scope here; its folder field
// would pull the mocked picker module back in.
vi.mock('@/components/layout/ProjectEditorModal', () => ({
ProjectEditorModal: () => null,
}))
vi.mock('../components/controls/PermissionModeSelector', () => ({
PermissionModeSelector: ({ compact, value, onChange }: { compact?: boolean; value?: string; onChange?: (mode: string) => void }) => (
<button
type="button"
data-testid="permission-mode-selector"
data-compact={compact ? 'true' : 'false'}
aria-label={`Permission mode: ${value ?? 'default'}`}
onClick={() => onChange?.('auto')}
>
{value ?? 'default'}
</button>
),
}))
vi.mock('../components/controls/ModelSelector', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
ModelSelector: React.forwardRef<{ open: () => void }, { compact?: boolean }>(({ compact }, ref) => {
const [open, setOpen] = React.useState(false)
React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), [])
return (
<>
<button type="button" data-testid="model-selector" data-compact={compact ? 'true' : 'false'}>
Model
</button>
{open && <div data-testid="model-selector-dropdown">Model selector opened</div>}
</>
)
}),
}
})
import { EmptySession } from './EmptySession'
import { ApiError } from '../api/client'
import { useChatStore } from '../stores/chatStore'
import { useProviderStore } from '../stores/providerStore'
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'
import { usePluginStore } from '../stores/pluginStore'
import { useWorkflowStore } from '../stores/workflowStore'
import type { RepositoryContextResult } from '../api/sessions'
import { browserHost } from '../lib/desktopHost/browserHost'
import { getComposerElement, getComposerText, getComposerView, setComposerSelection, setComposerText } from '../components/chat/composerTestUtils'
import { useVoiceInputStore } from '../stores/voiceInputStore'
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
return {
state: 'ok',
workDir: '/workspace/project',
repoRoot: '/workspace/project',
repoName: 'project',
currentBranch: 'main',
defaultBranch: 'main',
dirty: false,
branches: [{
name: 'main',
current: true,
local: true,
remote: false,
checkedOut: true,
worktreePath: '/workspace/project',
}],
worktrees: [{
path: '/workspace/project',
branch: 'main',
current: true,
}],
...overrides,
}
}
function notGitRepositoryContext(): RepositoryContextResult {
return {
state: 'not_git_repo',
workDir: '/workspace/project',
repoRoot: null,
repoName: null,
currentBranch: null,
defaultBranch: null,
dirty: false,
branches: [],
worktrees: [],
}
}
/** Opens the run-location pill's menu. */
async function openLaunchMenu() {
fireEvent.click(await screen.findByRole('button', { name: /^Location/ }))
}
/**
* Picks the mocked project. The directory list is no longer a standing button
* on a bar under the composer — it is a view of the run-location pill's menu,
* which a fresh session opens directly onto.
*/
async function pickProject() {
await openLaunchMenu()
fireEvent.click(await screen.findByRole('button', { name: 'Pick project' }))
// Picking a repo holds the menu open on the root view, where the branch and
// worktree rows have just appeared. Close it so callers start from a clean
// slate and open it themselves when they mean to.
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Pick project' })).not.toBeInTheDocument()
})
}
describe('EmptySession', () => {
const initialSessionState = useSessionStore.getInitialState()
const initialChatState = useChatStore.getInitialState()
const initialTabState = useTabStore.getInitialState()
const initialRuntimeState = useSessionRuntimeStore.getInitialState()
const initialUiState = useUIStore.getInitialState()
const initialPluginState = usePluginStore.getInitialState()
const initialProviderState = useProviderStore.getInitialState()
const initialWorkflowState = useWorkflowStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
// ProseMirror selection scrolling needs geometry APIs absent in jsdom.
Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => ({
x: 0, y: 0, top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0,
toJSON: () => ({}),
}) },
})
mocks.webviewDragHandlers.length = 0
mocks.isMobile = false
mocks.isTauriRuntime = false
mocks.voiceSupported.mockReturnValue(false)
useVoiceInputStore.setState({ catalog: null, loading: false, error: null })
useSettingsStore.setState({ locale: 'en', activeProviderName: null, permissionMode: 'default' })
useSessionStore.setState(initialSessionState, true)
useChatStore.setState(initialChatState, true)
useTabStore.setState(initialTabState, true)
useSessionRuntimeStore.setState(initialRuntimeState, true)
useUIStore.setState(initialUiState, true)
usePluginStore.setState(initialPluginState, true)
useProviderStore.setState(initialProviderState, true)
useWorkflowStore.setState(initialWorkflowState, true)
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
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.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [] })
mocks.search.mockResolvedValue({
currentPath: '/workspace/project',
parentPath: null,
query: '',
entries: [],
})
mocks.browse.mockResolvedValue({ currentPath: '/workspace/project', parentPath: null, entries: [] })
mocks.getTasksForList.mockResolvedValue({ tasks: [] })
mocks.resetTaskList.mockResolvedValue(undefined)
mocks.getProviderAuthStatus.mockResolvedValue({
hasAuth: true,
source: 'cc-haha-provider',
})
})
afterEach(() => {
cleanup()
for (const [name, descriptor] of [
['getClientRects', originalRangeGetClientRects],
['getBoundingClientRect', originalRangeGetBoundingClientRect],
] as const) {
if (descriptor) Object.defineProperty(Range.prototype, name, descriptor)
else Reflect.deleteProperty(Range.prototype, name)
}
Reflect.deleteProperty(window, 'desktopHost')
useSessionStore.setState(initialSessionState, true)
useChatStore.setState(initialChatState, true)
useTabStore.setState(initialTabState, true)
useSessionRuntimeStore.setState(initialRuntimeState, true)
useUIStore.setState(initialUiState, true)
usePluginStore.setState(initialPluginState, true)
useProviderStore.setState(initialProviderState, true)
useWorkflowStore.setState(initialWorkflowState, true)
})
it('drops a starter into the draft and keeps what was already typed', async () => {
render(<EmptySession />)
const starters = screen.getByRole('group', { name: 'Suggestions' })
fireEvent.click(within(starters).getByRole('button', { name: 'Fix the failing tests' }))
await waitFor(() => expect(getComposerText()).toBe('Fix the failing tests'))
// A second pick appends on a new line rather than replacing the draft.
fireEvent.click(within(starters).getByRole('button', { name: 'Build a new feature' }))
await waitFor(() => expect(getComposerText()).toBe('Fix the failing tests\nBuild a new feature'))
})
it('keeps the starter row off the phone layout, where the composer docks to the bottom', () => {
mocks.isMobile = true
render(<EmptySession />)
expect(screen.queryByTestId('new-session-starter')).not.toBeInTheDocument()
expect(screen.getByTestId('empty-session-composer-shell')).toHaveClass('absolute', 'bottom-0')
})
it('puts the project above the hero on a phone, where the keyboard cannot cover it', async () => {
mocks.isMobile = true
render(<EmptySession />)
const launch = screen.getByTestId('empty-session-mobile-launch')
const pill = await within(launch).findByRole('button', { name: /^Location:/ })
expect(pill).toHaveClass('h-10')
// Read before the hero and outside the docked composer.
expect(launch.compareDocumentPosition(screen.getByRole('heading', { level: 1 })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByTestId('empty-session-composer-shell')).not.toContainElement(pill)
expect(screen.getAllByRole('button', { name: /^Location:/ })).toHaveLength(1)
})
it('starts in the folder it is given, holding Run until that repository is read', async () => {
mocks.isMobile = true
// Its own id: the session store keeps per-id bookkeeping across tests.
mocks.createSession.mockResolvedValue({ sessionId: 'phone-session' })
let resolveContext: (context: RepositoryContextResult) => void = () => {}
mocks.getRepositoryContext.mockReturnValue(new Promise<RepositoryContextResult>((resolve) => {
resolveContext = resolve
}))
render(<EmptySession initialWorkDir="/workspace/project" />)
setComposerText('draft question', 14)
const runButton = screen.getByRole('button', { name: /Run/i })
expect(mocks.getRepositoryContext).toHaveBeenCalledWith('/workspace/project')
expect(runButton).toBeDisabled()
await act(async () => { resolveContext(okRepositoryContext()) })
expect(await screen.findByRole('button', { name: 'Location: project / main' })).toBeInTheDocument()
await waitFor(() => expect(runButton).not.toBeDisabled())
fireEvent.click(runButton)
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
permissionMode: 'default',
})
})
await waitFor(() => {
expect(mocks.wsSend).toHaveBeenCalledWith('phone-session', expect.objectContaining({ content: 'draft question' }))
})
})
it('uses compact composer controls on phone-sized H5 browsers', async () => {
mocks.isMobile = true
render(<EmptySession />)
await waitFor(() => {
expect(screen.getByTestId('permission-mode-selector')).toHaveAttribute('data-compact', 'true')
})
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-compact', 'true')
expect(screen.getByRole('button', { name: 'Run' })).toHaveClass('h-11', 'w-11')
expect(screen.getByTestId('empty-session-composer-shell')).toHaveClass('px-3')
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-[var(--radius-xl)]')
})
it.each(['@', '/', '/empty', '+'] as const)('hides withdrawn bundled capabilities in %s while keeping personal skills and other plugins', async (entry) => {
const withdrawnPackage = 'office-frontend-design@haha-connectors'
mocks.listReferences.mockResolvedValue({
plugins: [
{ kind: 'plugin', id: withdrawnPackage, name: 'office-frontend-design', displayName: 'Removed frontend plugin', description: 'Design', source: withdrawnPackage, modelText: 'Use removed plugin' },
{ kind: 'plugin', id: 'design-tools@community', name: 'design-tools', displayName: 'Design tools', description: 'Design', source: 'community', modelText: 'Use design tools' },
],
skills: [
{ kind: 'skill', id: 'office-frontend-design:frontend-design', name: 'frontend-design', displayName: 'Removed frontend skill', description: 'Design', source: withdrawnPackage, modelText: '/office-frontend-design:frontend-design' },
{ kind: 'skill', id: 'frontend-design', name: 'frontend-design', displayName: 'Personal frontend design', description: 'Design', source: 'user', modelText: '/frontend-design' },
],
})
const legacyCommands = [
{ name: 'office-frontend-design:frontend-design', description: 'Removed frontend skill', kind: 'skill' as const, source: 'plugin' as const, userInvocable: true },
{ name: 'frontend-design', description: 'Personal frontend design', kind: 'skill' as const, source: 'user' as const, userInvocable: true },
]
mocks.listSkills.mockResolvedValue({ skills: legacyCommands })
render(<EmptySession />)
if (entry === '+') {
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'design' } })
} else if (entry === '/empty') setComposerText('/', 1)
else setComposerText(`${entry}design`, 7)
expect(await screen.findByRole('option', { name: 'Personal frontend design' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'Design tools' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Removed frontend/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /office-frontend-design/ })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
if (entry === '/empty') {
const options = screen.getAllByRole('option')
const skillIndex = options.indexOf(screen.getByRole('option', { name: 'Personal frontend design' }))
const pluginIndex = options.indexOf(screen.getByRole('option', { name: 'Design tools' }))
expect(skillIndex).toBeGreaterThan(options.indexOf(screen.getByRole('option', { name: '/model' })))
expect(pluginIndex).toBeGreaterThan(skillIndex)
for (let index = 0; index < pluginIndex; index++) fireEvent.keyDown(getComposerElement(), { key: 'ArrowDown' })
expect(screen.getByRole('option', { name: 'Design tools' })).toHaveAttribute('aria-selected', 'true')
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(document.querySelector('.composer-mention')).toHaveTextContent('Design tools')
expect(mocks.wsSend).not.toHaveBeenCalled()
}
})
it.each(['empty', 'replacement'] as const)('refreshes skills when opening plus and drops stale entries during a pending %s response', async (result) => {
const oldSkill = { kind: 'skill' as const, id: 'old-skill', name: 'old-skill', displayName: 'Old skill', description: 'Previously enabled', source: 'user', modelText: '/old-skill' }
const newSkill = { ...oldSkill, id: 'new-skill', name: 'new-skill', displayName: 'New skill', modelText: '/new-skill' }
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [oldSkill] })
render(<EmptySession />)
await act(async () => { await Promise.resolve() })
const initialCalls = mocks.listReferences.mock.calls.length
let resolveRefresh!: (value: { plugins: [], skills: typeof oldSkill[] }) => void
mocks.listReferences.mockImplementation(() => new Promise(resolve => { resolveRefresh = resolve }))
fireEvent.click(screen.getByLabelText('Open composer tools'))
await waitFor(() => expect(mocks.listReferences.mock.calls.length).toBeGreaterThan(initialCalls))
fireEvent.click(await screen.findByRole('option', { name: /^Skills/ }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
await act(async () => resolveRefresh({ plugins: [], skills: result === 'replacement' ? [newSkill] : [] }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
if (result === 'replacement') expect(await screen.findByRole('option', { name: 'New skill' })).toBeInTheDocument()
else expect(screen.queryByRole('option', { name: 'New skill' })).not.toBeInTheDocument()
})
it('inserts a structured project file mention selected through the plus menu search', async () => {
mocks.search.mockResolvedValue({
currentPath: '/workspace/project', parentPath: null, query: 'README',
entries: [{ name: 'README.md', path: '/workspace/project/README.md', relativePath: 'README.md', isDirectory: false }],
})
render(<EmptySession />)
await pickProject()
setComposerText('Please review ', 14)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'README' } })
fireEvent.click(await screen.findByRole('option', { name: 'README.md' }))
await waitFor(() => {
expect(document.querySelector('.composer-mention')).toHaveAttribute('data-mention-path', '/workspace/project/README.md')
})
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
expect(getComposerText()).toContain('Please review @README.md')
expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project', { signal: expect.any(AbortSignal) })
expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(mocks.createSession).not.toHaveBeenCalled()
})
it('keeps user-only skills as slash text when no mention capability is available', async () => {
mocks.listSkills.mockResolvedValue({ skills: [{ name: 'manual-only', description: 'User invocation only', userInvocable: true, disableModelInvocation: true }] })
render(<EmptySession />)
setComposerText('/manual', 7)
fireEvent.click(await screen.findByText('manual-only'))
await waitFor(() => expect(getComposerText()).toBe('/manual-only '))
expect(document.querySelector('.composer-mention')).not.toBeInTheDocument()
expect(mocks.createSession).not.toHaveBeenCalled()
})
it('hides previous directory skills while the new directory is loading', async () => {
let resolveProject!: (result: { skills: { name: string, description: string, userInvocable: boolean }[] }) => void
mocks.listSkills.mockImplementation((cwd?: string) => cwd
? new Promise(resolve => { resolveProject = resolve })
: Promise.resolve({ skills: [{ name: 'old-directory-skill', description: 'Old scope', userInvocable: true }] }))
render(<EmptySession />)
setComposerText('/directory', 10)
expect(await screen.findByText('old-directory-skill')).toBeInTheDocument()
await pickProject()
setComposerText('/directory', 10)
expect(screen.queryByText('old-directory-skill')).not.toBeInTheDocument()
await act(async () => resolveProject({ skills: [{ name: 'new-directory-skill', description: 'New scope', userInvocable: true }] }))
expect(await screen.findByText('new-directory-skill')).toBeInTheDocument()
expect(screen.queryByText('old-directory-skill')).not.toBeInTheDocument()
})
it('ignores late mention candidates from a previous directory', async () => {
const staleResolvers: ((result: { plugins: unknown[], skills: unknown[] }) => void)[] = []
const candidate = (id: string) => ({
kind: 'plugin', id, name: id, displayName: id, description: 'Plugin',
source: 'plugin', modelText: `Use ${id}`,
})
mocks.listReferences.mockImplementation((cwd?: string) => cwd
? Promise.resolve({ plugins: [candidate('current-plugin')], skills: [] })
: new Promise(resolve => { staleResolvers.push(resolve) }))
render(<EmptySession />)
await waitFor(() => expect(staleResolvers.length).toBeGreaterThan(0))
await pickProject()
setComposerText('@', 1)
expect(await screen.findByText('current-plugin')).toBeInTheDocument()
await act(async () => {
staleResolvers.forEach(resolve => resolve({ plugins: [candidate('stale-plugin')], skills: [] }))
})
expect(screen.queryByText('stale-plugin')).not.toBeInTheDocument()
expect(screen.getByText('current-plugin')).toBeInTheDocument()
})
it('refreshes empty-session slash commands after plugin reloads', async () => {
mocks.listSkills
.mockResolvedValueOnce({ skills: [] })
.mockResolvedValueOnce({
skills: [
{
name: 'draw:render',
description: 'Render with the drawing plugin.',
userInvocable: true,
},
],
})
render(<EmptySession />)
await waitFor(() => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
act(() => {
usePluginStore.setState({
lastReloadSummary: {
enabled: 1,
disabled: 0,
skills: 1,
agents: 0,
hooks: 0,
mcpServers: 0,
lspServers: 0,
errors: 0,
},
})
})
await waitFor(() => {
expect(mocks.listSkills).toHaveBeenCalledTimes(2)
})
})
it('finds /clear without recommending it in an empty session', async () => {
render(<EmptySession />)
setComposerText('/', 1)
expect(await screen.findByRole('listbox', { name: 'Slash commands' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '/clear' })).not.toBeInTheDocument()
setComposerText('/clear', 6)
expect(await screen.findByRole('option', { name: '/clear' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '/goal' })).not.toBeInTheDocument()
})
it('prioritizes enabled plugin slash commands by command name when filtering', async () => {
mocks.listSkills.mockResolvedValueOnce({
skills: [
{
name: 'agent-team-orchestrator',
description: 'Agent Teams can use Subagent orchestration.',
userInvocable: true,
},
{
name: 'lark-calendar',
description: 'Includes suggestion helpers.',
userInvocable: true,
},
{
name: 'superpowers:brainstorming',
description: 'Creative work planning.',
userInvocable: true,
},
],
})
render(<EmptySession />)
await waitFor(() => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
setComposerText('/su', 3)
await waitFor(() => {
const commandOptions = screen.getAllByRole('option')
expect(commandOptions[0]).toHaveTextContent('superpowers:brainstorming')
})
})
it('uses the grouped accessible slash menu and preserves skill source labels', async () => {
mocks.listSkills.mockResolvedValueOnce({
skills: [
{
name: 'project-audit',
description: 'Audit this project.',
source: 'project',
userInvocable: true,
},
{
name: 'drawing:render',
description: 'Render with the drawing plugin.',
source: 'plugin',
userInvocable: true,
},
],
})
render(<EmptySession />)
await waitFor(() => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
setComposerText('/a', 2)
const listbox = await screen.findByRole('listbox', { name: 'Slash commands' })
const combobox = screen.getByRole('combobox')
const systemCommand = screen.getByText('/status')
const skillsHeading = screen.getByText('Skills')
const projectSkill = screen.getByText('project-audit')
const pluginSkill = screen.getByText('drawing:render')
expect(systemCommand.compareDocumentPosition(skillsHeading)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
expect(skillsHeading.compareDocumentPosition(projectSkill)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
expect(projectSkill.closest('[role="option"]')).toHaveTextContent('Project')
expect(pluginSkill.closest('[role="option"]')).toHaveTextContent('Plugin')
expect(combobox).toHaveAttribute('aria-controls', listbox.id)
expect(combobox).toHaveAttribute(
'aria-activedescendant',
screen.getAllByRole('option')[0]!.id,
)
})
it('offers active agents as slash entries that insert /agent with the selected type', async () => {
mocks.listAgents.mockResolvedValue({
activeAgents: [
{
agentType: 'debugger',
description: 'Debug failures',
modelDisplay: 'OPUS',
source: 'userSettings',
isActive: true,
},
],
allAgents: [],
})
render(<EmptySession />)
await waitFor(() => {
expect(mocks.listAgents).toHaveBeenCalledWith(undefined)
})
setComposerText('/debug', 6)
const agentOption = await screen.findByText('/agent debugger')
fireEvent.click(agentOption)
expect(getComposerText()).toBe('/agent debugger ')
})
it('opens the draft model selector for /model without creating or sending a session', async () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',
})
render(<EmptySession />)
const input = getComposerElement()
setComposerText('/model', 6)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-selector-dropdown')).toHaveTextContent('Model selector opened')
expect(getComposerText()).toBe('')
})
// Same guard as the ChatInput hero test: this flex row is what the new-tab
// page renders, and an unbreakable run (long URL, hash) would otherwise
// grow the composer past the panel border.
it('keeps min-w-0 on the composer wrapper so unbreakable runs cannot widen it', () => {
render(<EmptySession />)
const wrapper = getComposerElement().parentElement
expect(wrapper).toHaveClass('flex-1')
expect(wrapper).toHaveClass('min-w-0')
})
it('shows /save-workflow help without creating or sending a session', async () => {
useSettingsStore.setState({ chatSendBehavior: 'enter' })
render(<EmptySession />)
setComposerText('/save-workflow', '/save-workflow'.length)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.getProviderAuthStatus).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByText('Save workflow')).toBeInTheDocument()
expect(screen.getByText(/Complete a workflow in this session/)).toBeInTheDocument()
expect(getComposerText()).toBe('')
})
it('selects a highlighted agent entry from /agent without creating a session', async () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',
})
mocks.listAgents.mockResolvedValue({
activeAgents: [
{
agentType: 'debugger',
description: 'Debug failures',
modelDisplay: 'OPUS',
source: 'userSettings',
isActive: true,
},
],
allAgents: [],
})
render(<EmptySession />)
await waitFor(() => {
expect(mocks.listAgents).toHaveBeenCalledWith(undefined)
})
const input = getComposerElement()
setComposerText('/agent', 6)
await screen.findByText('/agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(getComposerText()).toBe('/agent debugger ')
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
})
// The launch controls used to be a bar welded under the composer, which is
// what forced the panel's squared bottom edge and the third divider line.
// They are one pill in the toolbar now, so the panel is fully rounded and
// the row the pill sits in is the same one holding "+" and the model.
it('puts the run-location pill in the composer toolbar, not on a bar of its own', async () => {
render(<EmptySession />)
const panel = screen.getByTestId('empty-session-composer-panel')
// 16px corner and the composer shadow step — the same floating card as
// ChatInput's. The repository controls live inside this panel, so it
// must stay a single rounded block rather than a split top/bottom pair.
expect(panel).toHaveClass('rounded-[var(--radius-xl)]', 'p-2', 'glass-panel--composer')
expect(panel).not.toHaveClass('rounded-b-none')
await pickProject()
const pill = await screen.findByRole('button', { name: 'Location: project / main' })
expect(panel).toContainElement(pill)
expect(pill).toHaveClass('h-7')
// Same toolbar row as Run — that row is the whole point of the change.
const toolbarRow = pill.closest('.justify-between')
expect(toolbarRow).toContainElement(screen.getByRole('button', { name: /Run/i }))
})
it('creates a session with the selected project and branch when submitted', async () => {
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
expect(mocks.createSession).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
const runButton = screen.getByRole('button', { name: /Run/i })
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
fireEvent.click(runButton)
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
permissionMode: 'default',
})
})
expect(useTabStore.getState().activeTabId).toBe('draft-session')
expect(useTabStore.getState().tabs).toEqual([
{ sessionId: 'draft-session', title: 'draft question', type: 'session', status: 'idle' },
])
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'draft-session',
workDir: '/workspace/project',
})
const messages = useChatStore.getState().sessions['draft-session']?.messages ?? []
expect(messages[messages.length - 1]).toMatchObject({
type: 'user_text',
content: 'draft question',
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'draft question',
attachments: [],
})
expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session')
expect(useSessionRuntimeStore.getState().selections['draft-session']).toBeUndefined()
})
it('stores and replays a draft runtime only when the user explicitly selected one', async () => {
useSessionRuntimeStore.getState().setSelection('__draft__', {
providerId: 'provider-explicit',
modelId: 'model-explicit',
})
render(<EmptySession />)
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
providerId: 'provider-explicit',
modelId: 'model-explicit',
})
expect(useSessionRuntimeStore.getState().selections['__draft__']).toBeUndefined()
expect(mocks.wsSend.mock.calls.slice(0, 2)).toEqual([
[
'draft-session',
{
type: 'set_runtime_config',
providerId: 'provider-explicit',
modelId: 'model-explicit',
},
],
['draft-session', { type: 'prewarm_session' }],
])
})
it('creates a new session with the draft Auto permission mode', async () => {
render(<EmptySession />)
fireEvent.click(screen.getByRole('button', { name: 'Permission mode: default' }))
setComposerText('run automatically', 17)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'auto' })
})
})
it('carries the global effort into the first draft selection for ChatGPT Official', async () => {
useSettingsStore.setState({ effortLevel: 'low' })
useProviderStore.setState({
providers: [],
activeId: 'openai-official',
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
hasLoadedProviders: true,
})
render(<EmptySession />)
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(useSessionRuntimeStore.getState().selections['draft-session']).toMatchObject({
providerId: 'openai-official',
effortLevel: 'low',
})
})
it.each([true, false])('materializes raw provider models with 1M=%s before the first draft message', async (enabled) => {
useProviderStore.setState({
providers: [{
id: 'provider-minimax',
presetId: 'minimax',
name: 'MiniMax',
apiKey: 'sk-minimax',
baseUrl: 'https://api.minimaxi.com/anthropic',
apiFormat: 'anthropic',
runtimeKind: 'anthropic_compatible',
models: {
main: 'MiniMax-M3',
haiku: 'MiniMax-M3',
sonnet: 'MiniMax-M3',
opus: 'MiniMax-M3',
},
model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
toolSearchEnabled: true,
}],
activeId: 'provider-minimax',
providerOrder: ['provider-minimax', 'claude-official', 'openai-official', 'grok-official'],
hasLoadedProviders: true,
})
render(<EmptySession />)
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
providerId: 'provider-minimax',
modelId: enabled ? 'MiniMax-M3[1m]' : 'MiniMax-M3',
effortLevel: 'low',
})
expect(mocks.wsSend.mock.calls.slice(0, 3)).toEqual([
[
'draft-session',
{
type: 'set_runtime_config',
providerId: 'provider-minimax',
modelId: enabled ? 'MiniMax-M3[1m]' : 'MiniMax-M3',
effortLevel: 'low',
},
],
['draft-session', { type: 'prewarm_session' }],
[
'draft-session',
{
type: 'user_message',
content: 'draft question',
attachments: [],
},
],
])
})
it('materializes the resolved Claude OAuth model before the first draft message', async () => {
mocks.getProviderAuthStatus.mockResolvedValue({
hasAuth: true,
source: 'claude-oauth',
activeProvider: 'Claude Official',
})
useSettingsStore.setState({
currentModel: {
id: 'claude-sonnet-5',
name: 'Sonnet 5',
description: 'Claude OAuth Pro default',
context: '1m',
},
effortLevel: 'high',
activeProviderName: null,
})
useProviderStore.setState({
providers: [],
activeId: null,
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
hasLoadedProviders: true,
})
render(<EmptySession />)
setComposerText('Claude OAuth question', 21)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
providerId: null,
modelId: 'claude-sonnet-5',
effortLevel: 'high',
})
expect(mocks.wsSend.mock.calls.slice(0, 3)).toEqual([
[
'draft-session',
{
type: 'set_runtime_config',
providerId: null,
modelId: 'claude-sonnet-5',
effortLevel: 'high',
},
],
['draft-session', { type: 'prewarm_session' }],
[
'draft-session',
{
type: 'user_message',
content: 'Claude OAuth question',
attachments: [],
},
],
])
})
it('opens provider settings instead of creating a session when no model authentication exists', async () => {
mocks.getProviderAuthStatus.mockResolvedValue({ hasAuth: false, source: 'none' })
render(<EmptySession />)
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.getProviderAuthStatus).toHaveBeenCalledTimes(1)
})
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(useUIStore.getState().pendingSettingsTab).toBe('providers')
expect(useTabStore.getState().activeTabId).toBe('__settings__')
})
it('uses native desktop file paths for draft attachments', async () => {
mocks.isTauriRuntime = true
window.desktopHost = {
kind: 'electron',
isDesktop: true,
capabilities: {
appMode: false,
dialogs: true,
notifications: false,
previewWebview: false,
shell: false,
terminal: false,
updates: false,
windowControls: false,
zoom: false,
},
dialogs: {
open: mocks.dialogOpen,
},
webview: {
onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten),
},
} as any
mocks.dialogOpen.mockResolvedValueOnce([
'C:\\Users\\Nanmi\\Desktop\\huge-a.log',
'/Users/nanmi/tmp/huge-b.zip',
])
render(<EmptySession />)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.click(screen.getByText('Add files or photos'))
expect(await screen.findByText('huge-a.log')).toBeInTheDocument()
expect(await screen.findByText('huge-b.zip')).toBeInTheDocument()
setComposerText('check these files', 'check these files'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'check these files',
attachments: [
expect.objectContaining({
type: 'file',
name: 'huge-a.log',
path: 'C:\\Users\\Nanmi\\Desktop\\huge-a.log',
data: undefined,
}),
expect.objectContaining({
type: 'file',
name: 'huge-b.zip',
path: '/Users/nanmi/tmp/huge-b.zip',
data: undefined,
}),
],
})
})
it('shows a drop affordance and sends dropped desktop files as path attachments', async () => {
mocks.isTauriRuntime = true
const droppedFile = new File(['large file'], 'ignored-name.log', { type: 'text/plain' })
Object.defineProperty(droppedFile, 'path', {
configurable: true,
value: '/Users/nanmi/drop/session-context.log',
})
const dataTransfer = {
types: ['Files'],
files: [droppedFile],
dropEffect: '',
}
render(<EmptySession />)
const panel = screen.getByTestId('empty-session-composer-panel')
fireEvent.dragEnter(panel, { dataTransfer })
expect(screen.getByTestId('empty-session-drop-overlay')).toBeInTheDocument()
fireEvent.drop(panel, { dataTransfer })
expect(await screen.findByText('session-context.log')).toBeInTheDocument()
expect(screen.queryByTestId('empty-session-drop-overlay')).not.toBeInTheDocument()
setComposerText('use this context', 'use this context'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'use this context',
attachments: [
expect.objectContaining({
type: 'file',
name: 'session-context.log',
path: '/Users/nanmi/drop/session-context.log',
data: undefined,
}),
],
})
})
it('pastes copied desktop files into a new-session draft as path attachments', async () => {
mocks.isTauriRuntime = true
const copiedFile = new File(['{\"name\":\"cc-haha\"}'], 'ignored-name.json', {
type: 'application/json',
})
Object.defineProperty(copiedFile, 'path', {
configurable: true,
value: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
})
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
webview: {
...browserHost.webview,
onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten),
},
}
render(<EmptySession />)
fireEvent.paste(getComposerElement(), {
clipboardData: {
files: [],
// ProseMirror reads text data before consulting our paste handler, so
// the stub has to answer like a real DataTransfer.
getData: () => '',
items: [{
kind: 'file',
type: 'application/json',
getAsFile: () => copiedFile,
}],
},
})
expect(await screen.findByText('project-context.json')).toBeInTheDocument()
setComposerText('use this context', 'use this context'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'use this context',
attachments: [
expect.objectContaining({
type: 'file',
name: 'project-context.json',
path: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
data: undefined,
}),
],
})
})
it('selects a plugin on the landing composer and sends its runtime request only after Run', async () => {
const modelText = 'Use plugin "hyperframes@curated" for this request.'
mocks.listReferences.mockResolvedValue({ skills: [], plugins: [{
kind: 'plugin', id: 'hyperframes@curated', name: 'hyperframes', displayName: 'HyperFrames',
description: 'Create HTML videos', source: 'curated', modelText,
}] })
render(<EmptySession />)
setComposerText('@hyper', 6)
fireEvent.click(await screen.findByRole('option', { name: 'HyperFrames' }))
await waitFor(() => expect(document.querySelector('[data-mention-kind="plugin"]')).toBeInTheDocument())
expect(mocks.createSession).not.toHaveBeenCalled()
fireEvent.keyDown(document.querySelector('[data-mention-kind="plugin"]')!, { key: 'Enter' })
expect(await screen.findByRole('dialog', { name: 'HyperFrames' })).toHaveTextContent('Create HTML videos')
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message', content: modelText, attachments: [],
}))
})
it('sends a selected @ directory as an inline @"path" in the first draft message', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',
parentPath: null,
query: 'backend',
entries: [
{ name: 'backend', path: '/workspace/project/backend', relativePath: 'backend', isDirectory: true },
],
})
render(<EmptySession />)
setComposerText('@backend 讲一下这个目录。', '@backend'.length)
fireEvent.click(await screen.findByRole('option', { name: /backend/i }))
await waitFor(() => {
expect(getComposerText()).toBe('@backend/ 讲一下这个目录。')
})
expect(document.querySelector('.composer-mention')).toHaveTextContent('@backend/')
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalled()
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: '@"/workspace/project/backend" 讲一下这个目录。',
attachments: [],
})
expect(getComposerText()).toBe('')
})
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',
parentPath: null,
query: '',
entries: [
{ name: 'README.md', path: '/workspace/project/README.md', isDirectory: false },
],
})
render(<EmptySession />)
const panel = screen.getByTestId('empty-session-composer-panel')
setComposerText('/mcp', 4)
expect(await screen.findByRole('option', { name: '/mcp' })).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
setComposerText('@readme', 7)
expect(await screen.findByText('README.md')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
})
it('starts in a selected non-Git project without showing a repository warning', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext())
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run/i })).not.toBeDisabled()
})
expect(screen.queryByText('Current project is not a Git repository.')).not.toBeInTheDocument()
// Without a repo the pill carries the folder alone, and there are no
// branch or worktree rows to drop back to — so the menu opens straight on
// the directory list instead of a root view holding a single row.
await openLaunchMenu()
expect(await screen.findByRole('button', { name: 'Pick project' })).toBeInTheDocument()
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Pick project' })).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
permissionMode: 'default',
})
})
})
it('shows an actionable repository error when direct branch switching is blocked', async () => {
mocks.createSession.mockRejectedValueOnce(new ApiError(400, {
error: 'REPOSITORY_DIRTY_WORKTREE',
message: 'Working tree has uncommitted changes.',
}))
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]?.message).toBe(
'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.',
)
})
expect(useTabStore.getState().activeTabId).toBeNull()
})
it('keeps Run disabled until repository context resolves for a selected project', async () => {
let resolveContext: (context: RepositoryContextResult) => void = () => {}
mocks.getRepositoryContext.mockImplementationOnce(() => new Promise<RepositoryContextResult>((resolve) => {
resolveContext = resolve
}))
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run/i })).toBeDisabled()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
expect(mocks.createSession).not.toHaveBeenCalled()
resolveContext(okRepositoryContext())
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run/i })).not.toBeDisabled()
})
})
it('falls back to a visible branch when the current branch is an internal desktop worktree branch', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: 'worktree-desktop-feature-a-12345678',
defaultBranch: 'main',
branches: [
{
name: 'main',
current: false,
local: true,
remote: false,
checkedOut: false,
},
{
name: 'feature/a',
current: false,
local: true,
remote: false,
checkedOut: false,
},
],
worktrees: [{
path: '/workspace/project/.claude/worktrees/desktop-feature-a-12345678',
branch: 'worktree-desktop-feature-a-12345678',
current: true,
}],
}))
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
expect(screen.queryByText('worktree-desktop-feature-a-12345678')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
permissionMode: 'default',
})
})
})
it('keeps the repository launch context on one row and truncates long branch names', async () => {
const longBranch = 'feature/super-long-branch-name-for-repository-launch-controls-e2e'
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: longBranch,
defaultBranch: 'main',
branches: [{
name: longBranch,
current: true,
local: true,
remote: false,
checkedOut: true,
worktreePath: '/workspace/project',
}],
worktrees: [{
path: '/workspace/project',
branch: longBranch,
current: true,
}],
}))
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
const pill = await screen.findByRole('button', { name: `Location: project / ${longBranch}` })
// The name sits in a <bdi>; the truncation and direction live on its wrapper.
const branchWrap = within(pill).getByText(longBranch).closest('[dir="rtl"]')
// Truncation happens inside the pill so the toolbar row never wraps.
expect(branchWrap?.className).toContain('truncate')
expect(pill.className).toContain('max-w-full')
// `dir="rtl"` moves the ellipsis to the front, so what survives is the
// tail — `…launch-controls-e2e`, not the useless `feature/super-long…`.
expect(branchWrap).not.toBeNull()
})
it('keeps current worktree selectable when the fallback branch is checked out elsewhere', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: null,
defaultBranch: 'main',
branches: [
{
name: 'main',
current: false,
local: true,
remote: false,
checkedOut: true,
worktreePath: '/workspace/project',
},
{
name: 'feature/a',
current: false,
local: true,
remote: false,
checkedOut: false,
},
],
worktrees: [{
path: '/workspace/project/.codex/worktrees/detached/project',
branch: null,
current: true,
}],
}))
render(<EmptySession />)
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
const warning = screen.getByRole('status', {
name: 'Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.',
})
expect(warning).toHaveTextContent('Branch already checked out')
expect(warning).toHaveAttribute(
'title',
'Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.',
)
// Staying on the current worktree has to remain a live choice even when the
// fallback branch is checked out elsewhere — it must not render disabled.
await openLaunchMenu()
const currentWorktree = await screen.findByRole('menuitemradio', { name: /Current worktree/ })
expect(currentWorktree).not.toBeDisabled()
expect(currentWorktree).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
permissionMode: 'default',
})
})
})
describe('voice input', () => {
let finishTranscription: (text: string) => void
let activeRecording: { getLevel: () => number; stop: ReturnType<typeof vi.fn>; cancel: ReturnType<typeof vi.fn> }
beforeEach(() => {
activeRecording = {
getLevel: () => 0,
stop: vi.fn(async () => ({ wav: new Blob(['wav']), seconds: 2 })),
cancel: vi.fn(),
}
mocks.voiceSupported.mockReturnValue(true)
useVoiceInputStore.setState({
catalog: {
supported: true,
providers: [{
info: { id: 'sensevoice-local', name: 'SenseVoice', location: 'local', languages: ['auto', 'zh'] },
preparation: { phase: 'ready' },
}],
preferences: { enabled: true, providerId: 'sensevoice-local', language: 'zh', downloadSource: 'auto' },
limits: { maxAudioSeconds: 60, maxAudioBytes: 1_000_000 },
},
})
mocks.voiceStartRecording.mockImplementation(async () => activeRecording)
mocks.voiceTranscribe.mockImplementation(() => new Promise((resolve) => {
finishTranscription = (text) => resolve({ text, audioSeconds: 2, inferenceSeconds: 0.1 })
}))
// Voice input exists only in the desktop app (the outer beforeEach resets this).
mocks.isTauriRuntime = true
// jsdom has no canvas; the recording bar's trace draws nothing without one.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
})
async function dictate() {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Dictate' }))
})
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Stop recording and transcribe' }))
})
await screen.findByRole('button', { name: 'Transcribing…' })
}
it('puts the microphone between the model picker and the send button', () => {
render(<EmptySession />)
const model = screen.getByTestId('model-selector')
const voice = screen.getByTestId('voice-input')
const run = screen.getByRole('button', { name: 'Run' })
expect(model.nextElementSibling).toBe(voice)
expect(voice.nextElementSibling).toBe(run)
})
it('does not render the microphone until dictation is ready', () => {
mocks.voiceSupported.mockReturnValue(false)
render(<EmptySession />)
expect(screen.queryByTestId('voice-input')).toBeNull()
})
it('does not render the microphone in the browser (H5)', () => {
mocks.isTauriRuntime = false
render(<EmptySession />)
expect(screen.queryByTestId('voice-input')).toBeNull()
})
it('writes dictated text at the caret without starting a session', async () => {
render(<EmptySession />)
setComposerText('ab', 1)
vi.spyOn(getComposerView(), 'hasFocus').mockReturnValue(true)
setComposerSelection(1)
await dictate()
await act(async () => {
finishTranscription('你好')
})
expect(getComposerText()).toBe('a你好b')
expect(mocks.createSession).not.toHaveBeenCalled()
})
it('starts the session with the dictated text when sent from the recording bar', async () => {
render(<EmptySession />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Dictate' }))
})
// The bar takes the toolbar's row: its controls, Run included, are hidden.
expect(screen.getByTestId('voice-recording-bar')).toBeVisible()
expect(screen.queryByRole('button', { name: 'Run' })).toBeNull()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Transcribe and send' }))
})
expect(mocks.createSession).not.toHaveBeenCalled()
await act(async () => {
finishTranscription('你好')
})
await waitFor(() => {
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: '你好',
attachments: [],
})
})
})
it('keeps the text aside when the draft was edited while it was being recognised', async () => {
render(<EmptySession />)
setComposerText('hello', 5)
await dictate()
setComposerText('hello there', 11)
await act(async () => {
finishTranscription('dictated')
})
expect(getComposerText()).toBe('hello there')
expect(screen.getByTestId('voice-input-pending-text')).toHaveTextContent('dictated')
})
it('holds a result that arrives during an IME composition in the composer', async () => {
render(<EmptySession />)
await dictate()
fireEvent.compositionStart(getComposerElement())
await act(async () => {
finishTranscription('你好')
})
expect(getComposerText()).toBe('')
expect(screen.getByTestId('voice-input-pending-text')).toHaveTextContent('你好')
})
it('releases the microphone when the page is left mid-recording', async () => {
const { unmount } = render(<EmptySession />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Dictate' }))
})
await screen.findByRole('button', { name: 'Stop recording and transcribe' })
unmount()
expect(activeRecording.cancel).toHaveBeenCalledTimes(1)
})
})
})