mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
fix: preserve repository context after session launch
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
This commit is contained in:
parent
0e705473ff
commit
09209ecefb
@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
getMessages: vi.fn(),
|
||||||
getGitInfo: vi.fn(),
|
getGitInfo: vi.fn(),
|
||||||
|
getSlashCommands: vi.fn(),
|
||||||
getRepositoryContext: vi.fn(),
|
getRepositoryContext: vi.fn(),
|
||||||
getRecentProjects: vi.fn(),
|
getRecentProjects: vi.fn(),
|
||||||
search: vi.fn(),
|
search: vi.fn(),
|
||||||
@ -13,7 +18,12 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../../api/sessions', () => ({
|
vi.mock('../../api/sessions', () => ({
|
||||||
sessionsApi: {
|
sessionsApi: {
|
||||||
|
create: mocks.create,
|
||||||
|
delete: mocks.delete,
|
||||||
|
list: mocks.list,
|
||||||
|
getMessages: mocks.getMessages,
|
||||||
getGitInfo: mocks.getGitInfo,
|
getGitInfo: mocks.getGitInfo,
|
||||||
|
getSlashCommands: mocks.getSlashCommands,
|
||||||
getRepositoryContext: mocks.getRepositoryContext,
|
getRepositoryContext: mocks.getRepositoryContext,
|
||||||
getRecentProjects: mocks.getRecentProjects,
|
getRecentProjects: mocks.getRecentProjects,
|
||||||
},
|
},
|
||||||
@ -142,6 +152,11 @@ describe('ChatInput file mentions', () => {
|
|||||||
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
||||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||||
mocks.getRecentProjects.mockResolvedValue({ projects: [] })
|
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 () => {
|
it('shows branch and worktree launch controls for an empty active Git session', async () => {
|
||||||
@ -188,6 +203,179 @@ describe('ChatInput file mentions', () => {
|
|||||||
expect(screen.queryByText('Select a project...')).not.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 () => {
|
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
|
||||||
mocks.search.mockResolvedValueOnce({
|
mocks.search.mockResolvedValueOnce({
|
||||||
currentPath: '/repo/backend/src',
|
currentPath: '/repo/backend/src',
|
||||||
|
|||||||
@ -96,7 +96,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const chatState = sessionState?.chatState ?? 'idle'
|
const chatState = sessionState?.chatState ?? 'idle'
|
||||||
const slashCommands = sessionState?.slashCommands ?? []
|
const slashCommands = sessionState?.slashCommands ?? []
|
||||||
const composerPrefill = sessionState?.composerPrefill ?? null
|
const composerPrefill = sessionState?.composerPrefill ?? null
|
||||||
const messageCount = sessionState?.messages?.length ?? 0
|
|
||||||
const runtimeSelection = useSessionRuntimeStore((state) =>
|
const runtimeSelection = useSessionRuntimeStore((state) =>
|
||||||
activeTabId ? state.selections[activeTabId] : undefined,
|
activeTabId ? state.selections[activeTabId] : undefined,
|
||||||
)
|
)
|
||||||
@ -106,9 +105,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
: undefined
|
: undefined
|
||||||
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
||||||
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
||||||
|
const loadedMessageCount = sessionState?.messages?.length ?? 0
|
||||||
|
const messageCount = Math.max(loadedMessageCount, activeSession?.messageCount ?? 0)
|
||||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||||
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
|
|
||||||
const workspaceReferences = useWorkspaceChatContextStore(
|
const workspaceReferences = useWorkspaceChatContextStore(
|
||||||
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
||||||
)
|
)
|
||||||
@ -122,7 +122,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
||||||
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
||||||
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
||||||
const showLaunchControls = !isMemberSession && !hasMessages
|
const showLaunchControls = !isMemberSession && messageCount === 0
|
||||||
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
|
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
|
||||||
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
|
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
|
||||||
? resolveSlashUiAction(input.trim().slice(1))
|
? resolveSlashUiAction(input.trim().slice(1))
|
||||||
@ -965,7 +965,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
|
|
||||||
{!isMemberSession && (
|
{!isMemberSession && (
|
||||||
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
|
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
|
||||||
{hasMessages ? (
|
{messageCount > 0 ? (
|
||||||
<ProjectContextChip
|
<ProjectContextChip
|
||||||
workDir={resolvedWorkDir}
|
workDir={resolvedWorkDir}
|
||||||
repoName={gitInfo?.repoName || null}
|
repoName={gitInfo?.repoName || null}
|
||||||
|
|||||||
@ -75,6 +75,57 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ActiveSession task polling', () => {
|
describe('ActiveSession task polling', () => {
|
||||||
|
it('treats a persisted historical session as non-empty before messages finish loading', () => {
|
||||||
|
const sessionId = 'history-loading-session'
|
||||||
|
|
||||||
|
useSessionStore.setState({
|
||||||
|
sessions: [{
|
||||||
|
id: sessionId,
|
||||||
|
title: 'History Loading Session',
|
||||||
|
createdAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
messageCount: 2,
|
||||||
|
projectPath: '/workspace/project',
|
||||||
|
workDir: '/workspace/project',
|
||||||
|
workDirExists: true,
|
||||||
|
}],
|
||||||
|
activeSessionId: sessionId,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
useTabStore.setState({
|
||||||
|
tabs: [{ sessionId, title: 'History Loading Session', type: 'session', status: 'idle' }],
|
||||||
|
activeTabId: 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(<ActiveSession />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('message-list')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -260,7 +260,7 @@ export function ActiveSession() {
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const messages = sessionState?.messages ?? []
|
const messages = sessionState?.messages ?? []
|
||||||
const streamingText = sessionState?.streamingText ?? ''
|
const streamingText = sessionState?.streamingText ?? ''
|
||||||
const isEmpty = messages.length === 0 && !streamingText
|
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
|
||||||
|
|
||||||
const isActive = chatState !== 'idle'
|
const isActive = chatState !== 'idle'
|
||||||
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user