diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index d4ebf8a4..9765d29a 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -299,9 +299,17 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/turn-checkpoints`) }, - getTurnCheckpointDiff(sessionId: string, targetUserMessageId: string, workspacePath: string) { + getTurnCheckpointDiff( + sessionId: string, + targetUserMessageId: string, + workspacePath: string, + userMessageIndex?: number, + ) { const query = new URLSearchParams() query.set('targetUserMessageId', targetUserMessageId) + if (Number.isInteger(userMessageIndex)) { + query.set('userMessageIndex', String(userMessageIndex)) + } query.set('path', workspacePath) return api.get( `/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`, diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index c425bbd8..edd27666 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -60,7 +60,12 @@ export function CurrentTurnChangeCard({ })) void sessionsApi - .getTurnCheckpointDiff(sessionId, targetUserMessageId, fileEntry.apiPath) + .getTurnCheckpointDiff( + sessionId, + targetUserMessageId, + fileEntry.apiPath, + checkpoint.target.userMessageIndex, + ) .then((result) => { setDiffByPath((current) => ({ ...current, diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 140884cf..fff5e34f 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -838,6 +838,7 @@ describe('MessageList nested tool calls', () => { ACTIVE_TAB, 'user-1', 'src/first.ts', + 0, ) expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled() }) @@ -905,6 +906,65 @@ describe('MessageList nested tool calls', () => { ACTIVE_TAB, 'user-1', '/tmp/old-project/src/first.ts', + 0, + ) + }) + + it('matches live turn change checkpoints by user message index when transcript ids differ from local UI ids', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'transcript-user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/live.ts'], + insertions: 7, + deletions: 0, + }, + }, + ], + }) + vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({ + state: 'ok', + path: 'src/live.ts', + diff: 'diff --session a/src/live.ts b/src/live.ts\n+live', + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'local-user-temp-id', + type: 'user_text', + content: '实时这一轮', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('src/live.ts')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show diff for src/live.ts' })) + await screen.findByTestId('workspace-code') + expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith( + ACTIVE_TAB, + 'transcript-user-1', + 'src/live.ts', + 0, ) }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index db6c2eb9..36844b25 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -338,10 +338,15 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const targetByMessageId = new Map( completedTurnTargets.map((target) => [target.messageId, target] as const), ) + const targetByUserMessageIndex = new Map( + completedTurnTargets.map((target) => [target.userMessageIndex, target] as const), + ) setTurnChangeCards( normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => { - const target = targetByMessageId.get(checkpoint.target.targetUserMessageId) + const target = + targetByMessageId.get(checkpoint.target.targetUserMessageId) ?? + targetByUserMessageIndex.get(checkpoint.target.userMessageIndex) if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) { return [] } @@ -475,7 +480,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { { expect(createdFileBody.diff).toContain('/dev/null') }) + it('GET /api/sessions/:id/turn-checkpoints should fall back to transcript tool changes when file snapshots are missing', async () => { + const sessionId = '99999999-bbbb-cccc-dddd-000000000001' + const workDir = path.join(tmpDir, 'transcript-only-session') + const userId = crypto.randomUUID() + await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true }) + + await writeSessionFile('-tmp-transcript-only-session', sessionId, [ + makeSessionMetaEntry(workDir), + { + ...makeUserEntry('build a todo app', userId), + cwd: workDir, + sessionId, + }, + makeAssistantToolUseEntry([ + { + id: 'Write:1', + name: 'Write', + input: { + file_path: path.join(workDir, 'todo-app', 'src', 'App.tsx'), + content: 'export function App() {\n return
Todo
\n}\n', + }, + }, + { + id: 'Write:2', + name: 'Write', + input: { + file_path: 'todo-app/vite.config.ts', + content: 'import { defineConfig } from "vite"\nexport default defineConfig({})\n', + }, + }, + ], userId), + makeAssistantEntry('Todo app created', userId), + ]) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) + expect(res.status).toBe(200) + const body = await res.json() as { + checkpoints: Array<{ + target: { targetUserMessageId: string } + code: { + available: boolean + filesChanged: string[] + insertions: number + deletions: number + } + workDir: string + }> + } + + expect(body.checkpoints).toHaveLength(1) + expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(userId) + expect(body.checkpoints[0]!.workDir).toBe(workDir) + expect(body.checkpoints[0]!.code.available).toBe(true) + expect(body.checkpoints[0]!.code.filesChanged.sort()).toEqual([ + path.join(workDir, 'todo-app', 'src', 'App.tsx'), + path.join(workDir, 'todo-app', 'vite.config.ts'), + ].sort()) + expect(body.checkpoints[0]!.code.insertions).toBe(5) + expect(body.checkpoints[0]!.code.deletions).toBe(0) + }) + + it('GET /api/sessions/:id/turn-checkpoints/diff should return transcript tool diffs when file snapshots are missing', async () => { + const sessionId = '99999999-bbbb-cccc-dddd-000000000002' + const workDir = path.join(tmpDir, 'transcript-only-diff-session') + const userId = crypto.randomUUID() + + await writeSessionFile('-tmp-transcript-only-diff-session', sessionId, [ + makeSessionMetaEntry(workDir), + { + ...makeUserEntry('edit config', userId), + cwd: workDir, + sessionId, + }, + makeAssistantToolUseEntry([ + { + id: 'Edit:1', + name: 'Edit', + input: { + file_path: path.join(workDir, 'todo-app', 'vite.config.ts'), + old_string: 'plugins: [react()]', + new_string: 'plugins: [react(), tailwindcss()]', + }, + }, + ], userId), + makeAssistantEntry('Config updated', userId), + ]) + + const res = await fetch( + `${baseUrl}/api/sessions/${sessionId}/turn-checkpoints/diff?targetUserMessageId=${userId}&path=${encodeURIComponent('todo-app/vite.config.ts')}`, + ) + expect(res.status).toBe(200) + const body = await res.json() as { + state: string + path: string + diff?: string + target: { targetUserMessageId: string } + } + + expect(body.target.targetUserMessageId).toBe(userId) + expect(body.state).toBe('ok') + expect(body.path).toBe('todo-app/vite.config.ts') + expect(body.diff).toContain('diff --session a/todo-app/vite.config.ts b/todo-app/vite.config.ts') + expect(body.diff).toContain('-plugins: [react()]') + expect(body.diff).toContain('+plugins: [react(), tailwindcss()]') + }) + + it('GET /api/sessions/:id/turn-checkpoints should include subagent transcript file changes for the parent turn', async () => { + const sessionId = '99999999-bbbb-cccc-dddd-000000000003' + const workDir = path.join(tmpDir, 'transcript-subagent-session') + const firstUserId = crypto.randomUUID() + const secondUserId = crypto.randomUUID() + const agentMessageId = crypto.randomUUID() + await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true }) + + await writeSessionFile('-tmp-transcript-subagent-session', sessionId, [ + makeSessionMetaEntry(workDir), + { + ...makeUserEntry('build a todo app', firstUserId), + cwd: workDir, + sessionId, + }, + { + parentUuid: firstUserId, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'Agent:todo', + name: 'Agent', + input: { description: 'Create todo app files' }, + }], + }, + uuid: agentMessageId, + timestamp: '2026-01-01T00:02:00.000Z', + }, + { + ...makeUserEntry('now explain it', secondUserId), + parentUuid: agentMessageId, + cwd: workDir, + sessionId, + }, + { + parentUuid: agentMessageId, + isSidechain: true, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'Write:child', + name: 'Write', + input: { + file_path: path.join(workDir, 'todo-app', 'src', 'Board.tsx'), + content: 'export function Board() {\n return null\n}\n', + }, + }], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:03:00.000Z', + }, + ]) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) + expect(res.status).toBe(200) + const body = await res.json() as { + checkpoints: Array<{ + target: { targetUserMessageId: string } + code: { filesChanged: string[]; insertions: number; deletions: number } + }> + } + + expect(body.checkpoints).toHaveLength(1) + expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(firstUserId) + expect(body.checkpoints[0]!.code.filesChanged).toEqual([ + path.join(workDir, 'todo-app', 'src', 'Board.tsx'), + ]) + expect(body.checkpoints[0]!.code.insertions).toBe(3) + expect(body.checkpoints[0]!.code.deletions).toBe(0) + }) + it('POST /api/sessions/:id/rewind should restore the base state when rewinding the first turn of a three-turn file history', async () => { const fixture = await createThreeTurnCheckpointFixture( 'aaaaaaaa-1111-2222-3333-444444444444', diff --git a/src/server/services/sessionRewindService.ts b/src/server/services/sessionRewindService.ts index 2904d4e1..91cfbc6b 100644 --- a/src/server/services/sessionRewindService.ts +++ b/src/server/services/sessionRewindService.ts @@ -1,6 +1,6 @@ import type { UUID } from 'crypto' import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises' -import { dirname, isAbsolute, join, relative } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { createTwoFilesPatch, diffLines } from 'diff' import { ApiError } from '../middleware/errorHandler.js' import { @@ -8,7 +8,7 @@ import { } from '../../utils/fileHistory.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { conversationService } from './conversationService.js' -import { sessionService } from './sessionService.js' +import { sessionService, type MessageEntry } from './sessionService.js' type RewindTarget = { targetUserMessageId: string @@ -25,6 +25,14 @@ type RewindCodePreview = { deletions: number } +type TranscriptFileChange = { + path: string + absolutePath: string + additions: number + deletions: number + diff?: string +} + export type RewindTargetSelector = { targetUserMessageId?: string userMessageIndex?: number @@ -409,6 +417,277 @@ function getNextUserMessageId( return userMessages[userMessageIndex + 1]?.id ?? null } +function isWithinBaseDir(absolutePath: string, baseDir: string): boolean { + const relativePath = relative(baseDir, absolutePath) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + +function normalizeTranscriptRelativePath(filePath: string): string { + return normalizeComparablePath(filePath).replace(/^\/+/, '') +} + +function resolveTranscriptToolPath( + filePath: unknown, + baseDir: string, +): { path: string; absolutePath: string } | null { + if (typeof filePath !== 'string' || !filePath.trim()) return null + const normalizedBaseDir = resolve(baseDir) + const absolutePath = isAbsolute(filePath) + ? resolve(filePath) + : resolve(normalizedBaseDir, filePath) + if (!isWithinBaseDir(absolutePath, normalizedBaseDir)) return null + + return { + path: normalizeTranscriptRelativePath(relative(normalizedBaseDir, absolutePath)), + absolutePath, + } +} + +function countTranscriptLines(content: string): number { + if (!content) return 0 + const lines = content.split(/\r\n|\r|\n/) + if (lines[lines.length - 1] === '') { + lines.pop() + } + return lines.length +} + +function buildTranscriptDiff( + oldPath: string, + newPath: string, + oldContent: string, + newContent: string, +): string { + const oldLines = oldContent ? oldContent.split('\n') : [] + const newLines = newContent ? newContent.split('\n') : [] + if (oldLines.at(-1) === '') oldLines.pop() + if (newLines.at(-1) === '') newLines.pop() + + return [ + `diff --session a/${oldPath} b/${newPath}`, + `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, + `+++ b/${newPath}`, + `@@ -1,${oldLines.length} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join('\n') +} + +function buildTranscriptEditChange( + filePath: { path: string; absolutePath: string }, + input: Record, +): TranscriptFileChange { + const oldString = typeof input.old_string === 'string' ? input.old_string : '' + const newString = typeof input.new_string === 'string' ? input.new_string : '' + return { + path: filePath.path, + absolutePath: filePath.absolutePath, + additions: countTranscriptLines(newString), + deletions: countTranscriptLines(oldString), + diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString), + } +} + +function extractApplyPatchTranscriptChanges( + patch: unknown, + baseDir: string, +): TranscriptFileChange[] { + if (typeof patch !== 'string') return [] + const changes: TranscriptFileChange[] = [] + + for (const line of patch.split('\n')) { + const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) + if (!match?.[1]) continue + const filePath = resolveTranscriptToolPath(match[1], baseDir) + if (!filePath) continue + changes.push({ + path: filePath.path, + absolutePath: filePath.absolutePath, + additions: 0, + deletions: 0, + }) + } + + return changes +} + +function extractTranscriptChangesFromTool( + toolName: string, + input: Record, + baseDir: string, +): TranscriptFileChange[] { + const normalizedToolName = toolName.toLowerCase() + if (normalizedToolName === 'write') { + const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) + if (!filePath) return [] + const content = typeof input.content === 'string' ? input.content : '' + return [{ + path: filePath.path, + absolutePath: filePath.absolutePath, + additions: countTranscriptLines(content), + deletions: 0, + diff: buildTranscriptDiff('/dev/null', filePath.path, '', content), + }] + } + + if (normalizedToolName === 'edit') { + const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) + if (!filePath) return [] + return [buildTranscriptEditChange(filePath, input)] + } + + if (normalizedToolName === 'multiedit') { + const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) + if (!filePath || !Array.isArray(input.edits)) return [] + return input.edits + .filter((edit): edit is Record => !!edit && typeof edit === 'object') + .map((edit) => buildTranscriptEditChange(filePath, edit)) + } + + if (normalizedToolName === 'notebookedit') { + const filePath = resolveTranscriptToolPath( + input.notebook_path ?? input.file_path ?? input.path, + baseDir, + ) + if (!filePath) return [] + const oldString = typeof input.old_source === 'string' ? input.old_source : '' + const newString = typeof input.new_source === 'string' ? input.new_source : '' + return [{ + path: filePath.path, + absolutePath: filePath.absolutePath, + additions: countTranscriptLines(newString), + deletions: countTranscriptLines(oldString), + diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString), + }] + } + + if (normalizedToolName === 'apply_patch') { + return extractApplyPatchTranscriptChanges(input.patch, baseDir) + } + + return [] +} + +function getToolUseIds(messages: MessageEntry[]): Set { + const ids = new Set() + for (const message of messages) { + if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue + for (const block of message.content) { + if (!block || typeof block !== 'object') continue + const record = block as Record + if (record.type === 'tool_use' && typeof record.id === 'string') { + ids.add(record.id) + } + } + } + return ids +} + +function getTranscriptTurnMessages( + activeMessages: MessageEntry[], + targetUserMessageId: string, +): MessageEntry[] { + const range = getTurnMessageRange(activeMessages, targetUserMessageId) + if (!range) return [] + + const rawTurnMessages = activeMessages.slice(range.start + 1, range.end) + const parentTurnMessages = rawTurnMessages.filter((message) => !message.parentToolUseId) + const turnToolUseIds = getToolUseIds(parentTurnMessages) + if (turnToolUseIds.size === 0) return parentTurnMessages + + const inlineChildMessages = rawTurnMessages.filter((message) => + message.parentToolUseId && turnToolUseIds.has(message.parentToolUseId) + ) + const turnMessages = [...parentTurnMessages, ...inlineChildMessages] + const includedIds = new Set(turnMessages.map((message) => message.id)) + const childMessages = activeMessages.filter((message) => + message.parentToolUseId && + turnToolUseIds.has(message.parentToolUseId) && + !includedIds.has(message.id) + ) + + return [...turnMessages, ...childMessages] +} + +function collectTranscriptTurnFileChanges( + activeMessages: MessageEntry[], + targetUserMessageId: string, + baseDir: string, +): TranscriptFileChange[] { + const turnMessages = getTranscriptTurnMessages(activeMessages, targetUserMessageId) + if (turnMessages.length === 0) return [] + + const changes = new Map() + for (const message of turnMessages) { + if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue + + for (const block of message.content) { + if (!block || typeof block !== 'object') continue + const record = block as Record + if (record.type !== 'tool_use' || typeof record.name !== 'string') continue + const input = record.input + if (!input || typeof input !== 'object') continue + + for (const change of extractTranscriptChangesFromTool( + record.name, + input as Record, + baseDir, + )) { + const existing = changes.get(change.path) + if (!existing) { + changes.set(change.path, change) + continue + } + + changes.set(change.path, { + ...existing, + additions: existing.additions + change.additions, + deletions: existing.deletions + change.deletions, + diff: [existing.diff, change.diff].filter(Boolean).join('\n'), + }) + } + } + } + + return [...changes.values()].sort((a, b) => a.path.localeCompare(b.path)) +} + +function buildTranscriptTurnCodePreview( + activeMessages: MessageEntry[], + targetUserMessageId: string, + baseDir: string, +): RewindCodePreview { + const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir) + if (changes.length === 0) { + return { + available: false, + reason: 'No transcript file changes were recorded for this turn.', + filesChanged: [], + insertions: 0, + deletions: 0, + } + } + + return normalizeDiffStats({ + filesChanged: changes.map((change) => change.absolutePath), + insertions: changes.reduce((total, change) => total + change.additions, 0), + deletions: changes.reduce((total, change) => total + change.deletions, 0), + }) +} + +function findTranscriptTurnDiff( + activeMessages: MessageEntry[], + targetUserMessageId: string, + baseDir: string, + requestedPath: string, +): TranscriptFileChange | null { + const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir) + return changes.find((change) => + matchesCheckpointPath(requestedPath, change.path, baseDir) || + normalizeComparablePath(requestedPath) === normalizeComparablePath(change.absolutePath) + ) ?? null +} + async function getTurnBoundaryContents( sessionId: string, checkpointBaseDir: string, @@ -670,15 +949,16 @@ export async function listSessionTurnCheckpoints( const nextSnapshot = nextUserMessageId && snapshots ? findTargetSnapshot(snapshots, nextUserMessageId) : null - const preview = targetSnapshot + const checkpointPreview = targetSnapshot ? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot) - : { - available: false, - reason: 'No file checkpoint is available for the selected message.', - filesChanged: [], - insertions: 0, - deletions: 0, - } + : null + const preview = checkpointPreview?.available && checkpointPreview.filesChanged.length > 0 + ? checkpointPreview + : buildTranscriptTurnCodePreview( + activeMessages, + target.targetUserMessageId, + checkpointBaseDir, + ) if (!preview.available || preview.filesChanged.length === 0) continue checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir)) @@ -699,6 +979,7 @@ export async function getSessionTurnCheckpointDiff( target.targetUserMessageId, workDir, ) + const activeMessages = await sessionService.getSessionMessages(sessionId) const snapshots = await loadFileHistorySnapshots(sessionId) const missingResult = { target: buildTurnPreview( @@ -715,17 +996,31 @@ export async function getSessionTurnCheckpointDiff( path: normalizeComparablePath(requestedPath), state: 'missing' as const, } + const transcriptChange = findTranscriptTurnDiff( + activeMessages, + target.targetUserMessageId, + checkpointBaseDir, + requestedPath, + ) + const transcriptResult = transcriptChange?.diff + ? { + target: missingResult.target, + workDir: checkpointBaseDir, + path: transcriptChange.path, + state: 'ok' as const, + diff: transcriptChange.diff, + } + : null if (!snapshots) { - return missingResult + return transcriptResult ?? missingResult } const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId) if (!targetSnapshot) { - return missingResult + return transcriptResult ?? missingResult } - const userMessages = (await sessionService.getSessionMessages(sessionId)) - .filter((message) => message.type === 'user') + const userMessages = activeMessages.filter((message) => message.type === 'user') const nextUserMessageId = getNextUserMessageId(userMessages, target.userMessageIndex) const nextSnapshot = nextUserMessageId ? findTargetSnapshot(snapshots, nextUserMessageId) @@ -781,7 +1076,7 @@ export async function getSessionTurnCheckpointDiff( } } - return missingResult + return transcriptResult ?? missingResult } export async function executeSessionRewind(