diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index f9e963b8..af05d42a 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -911,6 +911,180 @@ describe('MessageList nested tool calls', () => { }) }) + it('undoes only the latest completed turn when earlier turns also changed files', async () => { + vi.spyOn(sessionsApi, 'rewind') + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 7, + deletions: 2, + }, + }) + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-2', 'assistant-2'], + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 7, + deletions: 2, + }, + }) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) + const reloadHistory = vi.fn().mockResolvedValue(undefined) + const queueComposerPrefill = vi.fn() + + useChatStore.setState({ + reloadHistory, + queueComposerPrefill, + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮需求', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮需求', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('1 files changed')).toBeTruthy() + expect(screen.getByText('src/second.ts')).toBeTruthy() + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二轮需求', + dryRun: true, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' })) + const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' })) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二轮需求', + }) + }) + expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: '第二轮需求', + attachments: undefined, + }) + }) + + it('does not show a stale current-turn change card when the latest completed turn has no files', async () => { + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: [], + insertions: 0, + deletions: 0, + }, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮改文件', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮只解释', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二轮只解释', + dryRun: true, + }) + }) + expect(screen.queryByLabelText('Current turn changed files')).toBeNull() + }) + it('shows raw startup details under translated CLI startup errors', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index e72d397f..a517c878 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -88,6 +88,17 @@ async function clickElement(element: Element) { }) } +function classNameContains(element: Element | null, needle: string) { + let current = element + while (current) { + if (typeof current.className === 'string' && current.className.includes(needle)) { + return true + } + current = current.parentElement + } + return false +} + vi.mock('../../api/sessions', () => ({ sessionsApi: (() => { if (!mocks) { @@ -450,6 +461,60 @@ describe('WorkspacePanel', () => { expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) }) + it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-dark-theme': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-dark-theme': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-dark-theme': [{ + id: 'file:src/theme.ts', + path: 'src/theme.ts', + kind: 'file', + title: 'theme.ts', + language: 'typescript', + content: 'export const theme = "dark"', + state: 'ok', + size: 27, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-dark-theme': 'file:src/theme.ts', + }, + })) + + const view = await renderPanel('session-dark-theme') + const panel = view.getByTestId('workspace-panel') + const tabList = view.getByRole('tablist', { name: 'Preview tabs' }) + const codeSurface = view.getByTestId('workspace-code') + + expect(panel.className).toContain('bg-[var(--color-surface)]') + expect(panel.className).not.toContain('bg-white') + expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]') + expect(tabList.className).not.toContain('bg-white') + expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true) + expect(classNameContains(codeSurface, 'bg-white')).toBe(false) + }) + it('caps rendered preview lines to keep large diffs responsive', async () => { const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n') diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index b49b731e..39124659 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1726,6 +1726,86 @@ describe('Sessions API', () => { expect(remainingMessages[0]?.id).toBe(firstUserId) }) + it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => { + const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff' + const workDir = path.join(tmpDir, 'two-turns-separate-files') + const firstTurnFile = path.join(workDir, 'src', 'first.js') + const secondTurnFile = path.join(workDir, 'src', 'second.js') + const firstUserId = crypto.randomUUID() + const secondUserId = crypto.randomUUID() + const firstBaseBackup = 'separate-first@v1' + const firstAfterTurnBackup = 'separate-first@v2' + const secondBaseBackup = 'separate-second@v1' + + await fs.mkdir(path.dirname(firstTurnFile), { recursive: true }) + await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8') + await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8') + await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n") + await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n") + await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n") + + await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [ + makeSessionMetaEntry(workDir), + makeFileHistorySnapshotEntry(firstUserId, { + 'src/first.js': { + backupFileName: firstBaseBackup, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('make first file v1', firstUserId), + cwd: workDir, + sessionId, + }, + makeAssistantEntry('DONE first', firstUserId), + makeFileHistorySnapshotEntry(secondUserId, { + 'src/first.js': { + backupFileName: firstAfterTurnBackup, + version: 2, + backupTime: '2026-01-01T00:00:00.000Z', + }, + 'src/second.js': { + backupFileName: secondBaseBackup, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('make second file v2', secondUserId), + cwd: workDir, + sessionId, + }, + makeAssistantEntry('DONE second', secondUserId), + ]) + + const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 1, dryRun: true }), + }) + expect(previewRes.status).toBe(200) + const preview = await previewRes.json() as { + code: { available: boolean; filesChanged: string[] } + } + expect(preview.code.available).toBe(true) + expect(preview.code.filesChanged).toEqual([secondTurnFile]) + + const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 1 }), + }) + expect(executeRes.status).toBe(200) + + expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n") + expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n") + + const remainingMessages = await service.getSessionMessages(sessionId) + expect(remainingMessages).toHaveLength(2) + expect(remainingMessages[0]?.id).toBe(firstUserId) + }) + it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => { const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'created-on-second-turn')