From 09209ecefbf24090a9f081ac1899da561c360689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 7 May 2026 18:19:18 +0800 Subject: [PATCH] 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 --- .../src/components/chat/ChatInput.test.tsx | 188 ++++++++++++++++++ desktop/src/components/chat/ChatInput.tsx | 8 +- desktop/src/pages/ActiveSession.test.tsx | 51 +++++ desktop/src/pages/ActiveSession.tsx | 2 +- 4 files changed, 244 insertions(+), 5 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 1e926440..75741505 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -3,7 +3,12 @@ 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(), @@ -13,7 +18,12 @@ const mocks = vi.hoisted(() => ({ 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, }, @@ -142,6 +152,11 @@ describe('ChatInput file mentions', () => { 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 () => { @@ -188,6 +203,179 @@ describe('ChatInput file mentions', () => { 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() + + 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() + + 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() + + 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', diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 55d01630..2f7945ac 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -96,7 +96,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] const composerPrefill = sessionState?.composerPrefill ?? null - const messageCount = sessionState?.messages?.length ?? 0 const runtimeSelection = useSessionRuntimeStore((state) => activeTabId ? state.selections[activeTabId] : undefined, ) @@ -106,9 +105,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro : undefined const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id 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 [gitInfo, setGitInfo] = useState(null) - const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) const workspaceReferences = useWorkspaceChatContextStore( (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 isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined - const showLaunchControls = !isMemberSession && !hasMessages + const showLaunchControls = !isMemberSession && messageCount === 0 const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/') ? resolveSlashUiAction(input.trim().slice(1)) @@ -965,7 +965,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro {!isMemberSession && (
- {hasMessages ? ( + {messageCount > 0 ? ( { }) 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() + + 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 () => { vi.useFakeTimers() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 8ceb5fb1..83ec02be 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -260,7 +260,7 @@ export function ActiveSession() { const t = useTranslation() const messages = sessionState?.messages ?? [] 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 totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens