fix: restore per-turn changed file cards

The chat timeline lost per-turn changed file cards when the visible user message ids did not match persisted transcript ids, and sessions without SDK file-history snapshots had no fallback source for turn changes. Match live cards by stable user-message index when needed and derive checkpoint previews from transcript tool calls when snapshots are absent.

Constraint: Right-side workspace changes already derive from transcript tool calls, so chat checkpoint data must remain consistent with that source.
Rejected: Force a history reload after every turn | would hide the id mismatch but add churn and still not cover transcript-only sessions.
Confidence: high
Scope-risk: moderate
Directive: Keep snapshot checkpoints as the preferred source, and use transcript extraction only as the compatibility fallback.
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun test src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test
Tested: git diff --check
Not-tested: Browser E2E against a live model session.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 21:43:17 +08:00
parent f5cf5932b4
commit 103a6cc42b
6 changed files with 580 additions and 19 deletions

View File

@ -299,9 +299,17 @@ export const sessionsApi = {
return api.get<SessionTurnCheckpointsResponse>(`/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<TurnCheckpointDiffResult>(
`/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`,

View File

@ -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,

View File

@ -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(<MessageList />)
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,
)
})

View File

@ -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 = {
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.target.messageId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}

View File

@ -2099,6 +2099,194 @@ describe('Sessions API', () => {
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 <main>Todo</main>\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',

View File

@ -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<string, unknown>,
): 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<string, unknown>,
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<string, unknown> => !!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<string> {
const ids = new Set<string>()
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<string, unknown>
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<string, TranscriptFileChange>()
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<string, unknown>
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<string, unknown>,
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(