mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Empty ActiveSession launch controls now use both loaded chat messages and persisted session messageCount, so historical sessions do not briefly fall back into new-session launch mode while message history is still loading. Regression coverage also locks the first-message branch launch flows for direct checkout and isolated worktree sessions. Constraint: Historical session lists already know messageCount before chat history finishes loading Rejected: Wait for history fetch before rendering composer controls | it keeps the wrong new-session affordance visible during reopen Confidence: high Scope-risk: narrow Directive: Branch/worktree launch controls are only for messageCount-zero sessions; reopened history must render repository context chips Tested: bun run test -- --run src/components/chat/ChatInput.test.tsx src/pages/ActiveSession.test.tsx src/__tests__/pages.test.tsx Tested: bun run check:desktop Tested: agent-browser /tmp repo direct branch launch on /private/tmp/cc-haha-repo-e2e-IP9mkb produced session 9089ae96-5dd6-4ea6-b88e-376ab081ca24 on workDir /private/tmp/cc-haha-repo-e2e-IP9mkb Tested: agent-browser /tmp repo isolated worktree launch produced session 9ec657bd-e503-48b3-b52f-36e210fc5d64 on workDir /private/tmp/cc-haha-repo-e2e-IP9mkb/.claude/worktrees/desktop-main-9ec657bd Not-tested: root bun run verify; desktop surface was covered with check:desktop and live browser smoke
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import '@testing-library/jest-dom'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
delete: vi.fn(),
|
|
list: vi.fn(),
|
|
getMessages: vi.fn(),
|
|
getGitInfo: vi.fn(),
|
|
getSlashCommands: vi.fn(),
|
|
getRepositoryContext: vi.fn(),
|
|
getRecentProjects: vi.fn(),
|
|
search: vi.fn(),
|
|
browse: vi.fn(),
|
|
wsSend: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../api/sessions', () => ({
|
|
sessionsApi: {
|
|
create: mocks.create,
|
|
delete: mocks.delete,
|
|
list: mocks.list,
|
|
getMessages: mocks.getMessages,
|
|
getGitInfo: mocks.getGitInfo,
|
|
getSlashCommands: mocks.getSlashCommands,
|
|
getRepositoryContext: mocks.getRepositoryContext,
|
|
getRecentProjects: mocks.getRecentProjects,
|
|
},
|
|
}))
|
|
|
|
vi.mock('../../api/filesystem', () => ({
|
|
filesystemApi: {
|
|
search: mocks.search,
|
|
browse: mocks.browse,
|
|
},
|
|
}))
|
|
|
|
vi.mock('../../api/websocket', () => ({
|
|
wsManager: {
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
onMessage: vi.fn(() => () => {}),
|
|
clearHandlers: vi.fn(),
|
|
send: mocks.wsSend,
|
|
},
|
|
}))
|
|
|
|
vi.mock('../controls/PermissionModeSelector', () => ({
|
|
PermissionModeSelector: () => <button type="button">Permissions</button>,
|
|
}))
|
|
|
|
vi.mock('../controls/ModelSelector', () => ({
|
|
ModelSelector: () => <button type="button">Model</button>,
|
|
}))
|
|
|
|
import { ChatInput } from './ChatInput'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
|
|
|
function okRepositoryContext() {
|
|
return {
|
|
state: 'ok' as const,
|
|
workDir: '/repo',
|
|
repoRoot: '/repo',
|
|
repoName: 'repo',
|
|
currentBranch: 'main',
|
|
defaultBranch: 'main',
|
|
dirty: false,
|
|
branches: [
|
|
{
|
|
name: 'main',
|
|
current: true,
|
|
local: true,
|
|
remote: false,
|
|
checkedOut: true,
|
|
worktreePath: '/repo',
|
|
},
|
|
{
|
|
name: 'feature/a',
|
|
current: false,
|
|
local: true,
|
|
remote: false,
|
|
checkedOut: false,
|
|
},
|
|
],
|
|
worktrees: [{
|
|
path: '/repo',
|
|
branch: 'main',
|
|
current: true,
|
|
}],
|
|
}
|
|
}
|
|
|
|
describe('ChatInput file mentions', () => {
|
|
const sessionId = 'session-file-mention'
|
|
const initialChatState = useChatStore.getInitialState()
|
|
const initialSessionState = useSessionStore.getInitialState()
|
|
const initialTabState = useTabStore.getInitialState()
|
|
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSettingsStore.setState({ locale: 'en' })
|
|
useChatStore.setState(initialChatState, true)
|
|
useSessionStore.setState(initialSessionState, true)
|
|
useTabStore.setState(initialTabState, true)
|
|
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
|
|
|
|
useTabStore.setState({
|
|
activeTabId: sessionId,
|
|
tabs: [{ sessionId, title: 'Project', type: 'session', status: 'idle' }],
|
|
})
|
|
useSessionStore.setState({
|
|
sessions: [{
|
|
id: sessionId,
|
|
title: 'Project',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 1,
|
|
projectPath: '/repo',
|
|
workDir: '/repo',
|
|
workDirExists: true,
|
|
}],
|
|
activeSessionId: sessionId,
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {
|
|
[sessionId]: {
|
|
messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
|
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
|
mocks.getRecentProjects.mockResolvedValue({ projects: [] })
|
|
mocks.create.mockResolvedValue({ sessionId: 'created-session', workDir: '/repo' })
|
|
mocks.delete.mockResolvedValue({ ok: true })
|
|
mocks.list.mockResolvedValue({ sessions: [], total: 0 })
|
|
mocks.getMessages.mockResolvedValue({ messages: [] })
|
|
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
|
|
})
|
|
|
|
it('shows branch and worktree launch controls for an empty active Git session', async () => {
|
|
useSessionStore.setState({
|
|
sessions: [{
|
|
id: sessionId,
|
|
title: 'Project',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 0,
|
|
projectPath: '/repo',
|
|
workDir: '/repo',
|
|
workDirExists: true,
|
|
}],
|
|
activeSessionId: sessionId,
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {
|
|
[sessionId]: {
|
|
messages: [],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<ChatInput variant="hero" />)
|
|
|
|
expect(await screen.findByRole('button', { name: /Select branch: main/ })).toBeInTheDocument()
|
|
expect(screen.getByText('Current worktree')).toBeInTheDocument()
|
|
expect(screen.queryByText('Select a project...')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('uses the persisted message count to keep reopened sessions in context mode while history loads', async () => {
|
|
useSessionStore.setState({
|
|
sessions: [{
|
|
id: sessionId,
|
|
title: 'Project',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 2,
|
|
projectPath: '/repo',
|
|
workDir: '/repo',
|
|
workDirExists: true,
|
|
}],
|
|
activeSessionId: sessionId,
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {
|
|
[sessionId]: {
|
|
messages: [],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<ChatInput variant="hero" />)
|
|
|
|
expect(await screen.findByText('repo')).toBeInTheDocument()
|
|
expect(screen.getByText('main')).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /Select branch:/ })).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Current worktree')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('starts an empty active session on the selected branch without an isolated worktree', async () => {
|
|
mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' })
|
|
useSessionStore.setState({
|
|
sessions: [{
|
|
id: sessionId,
|
|
title: 'Project',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 0,
|
|
projectPath: '/repo',
|
|
workDir: '/repo',
|
|
workDirExists: true,
|
|
}],
|
|
activeSessionId: sessionId,
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {
|
|
[sessionId]: {
|
|
messages: [],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<ChatInput variant="hero" />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
|
|
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
|
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
|
fireEvent.change(input, { target: { value: 'run on feature branch', selectionStart: 21 } })
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.create).toHaveBeenCalledWith({
|
|
workDir: '/repo',
|
|
repository: { branch: 'feature/a', worktree: false },
|
|
})
|
|
})
|
|
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
|
|
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
|
|
type: 'user_message',
|
|
content: 'run on feature branch',
|
|
attachments: [],
|
|
})
|
|
})
|
|
|
|
it('starts an empty active session on the selected branch inside an isolated worktree', async () => {
|
|
mocks.create.mockResolvedValueOnce({
|
|
sessionId: 'created-worktree',
|
|
workDir: '/repo/.claude/worktrees/desktop-feature-a-12345678',
|
|
})
|
|
mocks.list.mockImplementationOnce(() => new Promise(() => {}))
|
|
useSessionStore.setState({
|
|
sessions: [{
|
|
id: sessionId,
|
|
title: 'Project',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 0,
|
|
projectPath: '/repo',
|
|
workDir: '/repo',
|
|
workDirExists: true,
|
|
}],
|
|
activeSessionId: sessionId,
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {
|
|
[sessionId]: {
|
|
messages: [],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<ChatInput variant="hero" />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
|
|
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle worktree isolation' }))
|
|
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
|
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
|
fireEvent.change(input, { target: { value: 'run in a worktree', selectionStart: 17 } })
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.create).toHaveBeenCalledWith({
|
|
workDir: '/repo',
|
|
repository: { branch: 'feature/a', worktree: true },
|
|
})
|
|
})
|
|
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
|
|
expect(mocks.wsSend).toHaveBeenCalledWith('created-worktree', {
|
|
type: 'user_message',
|
|
content: 'run in a worktree',
|
|
attachments: [],
|
|
})
|
|
expect(useSessionStore.getState().sessions[0]?.workDir)
|
|
.toBe('/repo/.claude/worktrees/desktop-feature-a-12345678')
|
|
})
|
|
|
|
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
|
|
mocks.search.mockResolvedValueOnce({
|
|
currentPath: '/repo/backend/src',
|
|
parentPath: '/repo/backend',
|
|
query: 'conditions.py',
|
|
entries: [
|
|
{ name: 'conditions.py', path: '/repo/backend/src/conditions.py', isDirectory: false },
|
|
],
|
|
})
|
|
|
|
render(<ChatInput compact />)
|
|
|
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
|
const mention = '@backend/src/conditions.py'
|
|
fireEvent.change(input, {
|
|
target: {
|
|
value: `${mention} 记一下这个文件讲了什么东西。`,
|
|
selectionStart: mention.length,
|
|
},
|
|
})
|
|
|
|
fireEvent.click(await screen.findByText('conditions.py'))
|
|
|
|
await waitFor(() => {
|
|
expect(input.value).toBe('记一下这个文件讲了什么东西。')
|
|
})
|
|
expect(screen.getByText('conditions.py')).toBeInTheDocument()
|
|
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
|
type: 'user_message',
|
|
content: '记一下这个文件讲了什么东西。',
|
|
attachments: [{
|
|
type: 'file',
|
|
name: 'conditions.py',
|
|
path: '/repo/backend/src/conditions.py',
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
note: undefined,
|
|
quote: undefined,
|
|
}],
|
|
})
|
|
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
|
expect(messages[messages.length - 1]).toMatchObject({
|
|
type: 'user_text',
|
|
content: '记一下这个文件讲了什么东西。',
|
|
modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。',
|
|
attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }],
|
|
})
|
|
})
|
|
})
|