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')