diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 2a3af084aa..bd1a3a8fcc 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -5254,7 +5254,7 @@ describe('MessageList nested tool calls', () => {
expect(await screen.findByText('blank-response.ts')).toBeTruthy()
})
- it('keeps historical turn change cards visible while the next turn is running', async () => {
+ it('keeps checkpoint evidence while hiding change cards for a running background task', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
@@ -5283,7 +5283,7 @@ describe('MessageList nested tool calls', () => {
{
id: 'assistant-1',
type: 'assistant_text',
- content: 'done',
+ content: '我正准备查看 test123.md',
timestamp: 2,
},
]
@@ -5297,6 +5297,7 @@ describe('MessageList nested tool calls', () => {
render()
expect(await screen.findByText('first.ts')).toBeTruthy()
+ expect(screen.queryByText('Markdown')).toBeNull()
act(() => {
useChatStore.setState({
@@ -5322,6 +5323,7 @@ describe('MessageList nested tool calls', () => {
await waitFor(() => {
expect(screen.queryByText('first.ts')).toBeNull()
})
+ expect(screen.queryByText('Markdown')).toBeNull()
act(() => {
useChatStore.setState({
@@ -5347,6 +5349,7 @@ describe('MessageList nested tool calls', () => {
await waitFor(() => {
expect(screen.getByText('first.ts')).toBeTruthy()
})
+ expect(screen.queryByText('Markdown')).toBeNull()
})
it('does not load turn change cards while background tasks are still running', async () => {
@@ -5604,7 +5607,7 @@ describe('MessageList nested tool calls', () => {
{
id: 'assistant-2',
type: 'assistant_text',
- content: 'second done',
+ content: '我正准备查看 test123.md',
timestamp: 4,
},
],
@@ -5618,6 +5621,9 @@ describe('MessageList nested tool calls', () => {
expect(cards).toHaveLength(1)
expect(screen.getByText('first.ts')).toBeTruthy()
expect(screen.queryByText('second.ts')).toBeNull()
+ await waitFor(() => {
+ expect(screen.queryByText('Markdown')).toBeNull()
+ })
})
it('shows raw startup details under translated CLI startup errors', () => {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 234e74e0ab..94ce871d2a 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -793,6 +793,7 @@ function buildTurnCardInsertionMap(
const cardsByRenderIndex = new Map()
turnChangeCards.forEach((card) => {
+ if (card.checkpoint.code.filesChanged.length === 0) return
const renderIndex =
lastResponseIndexByTurnId.get(card.target.messageId) ??
userIndexByTurnId.get(card.target.messageId)
@@ -820,9 +821,7 @@ function buildChangedFilesByRenderIndex(
): Map {
const filesByTurnId = new Map()
for (const card of turnChangeCards) {
- if (card.checkpoint.code.filesChanged.length > 0) {
- filesByTurnId.set(card.target.messageId, card.checkpoint.code.filesChanged)
- }
+ filesByTurnId.set(card.target.messageId, card.checkpoint.code.filesChanged)
}
if (filesByTurnId.size === 0) return new Map()
@@ -2022,8 +2021,8 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
[renderItems, visibleTurnChangeCards],
)
const changedFilesByRenderIndex = useMemo(
- () => buildChangedFilesByRenderIndex(renderItems, visibleTurnChangeCards),
- [renderItems, visibleTurnChangeCards],
+ () => buildChangedFilesByRenderIndex(renderItems, turnChangeCards),
+ [renderItems, turnChangeCards],
)
const renderItemKeys = useMemo(
() => renderItems.map(getRenderItemKey),
@@ -2170,7 +2169,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
const target =
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)
- if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) {
+ if (!target || !checkpoint.code.available) {
return []
}
return [{
diff --git a/desktop/src/lib/assistantOutputTargets.test.ts b/desktop/src/lib/assistantOutputTargets.test.ts
index 3d65e3a78d..9bf552cc63 100644
--- a/desktop/src/lib/assistantOutputTargets.test.ts
+++ b/desktop/src/lib/assistantOutputTargets.test.ts
@@ -333,13 +333,26 @@ describe('extractAssistantOutputTargets with changedFiles reconciliation', () =>
})
})
- it('falls back to text-only behavior when changedFiles is empty', () => {
+ it('drops file mentions when changedFiles explicitly confirms no files changed', () => {
+ const targets = extractAssistantOutputTargets(
+ '我正准备查看 test123.md,服务地址是 http://localhost:5173/',
+ {
+ workDir: '/private/tmp',
+ changedFiles: [],
+ },
+ )
+
+ expect(targets).toHaveLength(1)
+ expect(targets).toMatchObject([
+ { kind: 'localhost-url', href: 'http://localhost:5173/' },
+ ])
+ })
+
+ it('falls back to text-only behavior when changedFiles is unavailable', () => {
const targets = extractAssistantOutputTargets('已创建 `index.html`', {
workDir: '/private/tmp',
- changedFiles: [],
})
- // No reconciliation → original bare-path behavior (mention kept as-is).
expect(targets).toMatchObject([{ kind: 'local-html', normalizedPath: 'index.html' }])
})
diff --git a/desktop/src/lib/assistantOutputTargets.ts b/desktop/src/lib/assistantOutputTargets.ts
index 8c4430ca28..a605a6986c 100644
--- a/desktop/src/lib/assistantOutputTargets.ts
+++ b/desktop/src/lib/assistantOutputTargets.ts
@@ -32,7 +32,8 @@ export type ExtractAssistantOutputTargetOptions = {
* file is corrected to the actual changed path (so `index.html` resolves to the
* `todo-app/index.html` that was really written), and a mentioned file that the
* turn never changed is dropped instead of pointing at a non-existent path.
- * Localhost URLs are unaffected. Omitted/empty → fall back to text-only behavior.
+ * Localhost URLs are unaffected. Omitted → fall back to text-only behavior;
+ * an empty array confirms the turn changed no files, so file targets are dropped.
*/
changedFiles?: string[]
}
@@ -239,7 +240,7 @@ export function extractAssistantOutputTargets(
results.push(candidate.target)
}
- if (options.changedFiles && options.changedFiles.length > 0) {
+ if (options.changedFiles !== undefined) {
return reconcileTargetsWithChangedFiles(results, options.changedFiles, workDir)
}
diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts
index 2dd7fbafea..0b78c774c4 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -5572,6 +5572,119 @@ describe('Sessions API', () => {
])
})
+ it('GET /api/sessions/:id/turn-checkpoints should keep an available empty preview for an unchanged snapshot-backed turn', async () => {
+ const sessionId = '99999999-bbbb-cccc-dddd-000000000005'
+ const workDir = path.join(tmpDir, 'unchanged-snapshot-session')
+ const targetFile = path.join(workDir, 'src', 'unchanged.ts')
+ const userId = crypto.randomUUID()
+ const backupName = 'unchanged-snapshot@v1'
+ const content = 'export const unchanged = true\n'
+
+ await fs.mkdir(path.dirname(targetFile), { recursive: true })
+ await fs.writeFile(targetFile, content, 'utf-8')
+ await writeFileHistoryBackup(sessionId, backupName, content)
+ await writeSessionFile('-tmp-unchanged-snapshot-session', sessionId, [
+ makeSessionMetaEntry(workDir),
+ makeFileHistorySnapshotEntry(userId, {
+ 'src/unchanged.ts': {
+ backupFileName: backupName,
+ version: 1,
+ backupTime: '2026-01-01T00:00:00.000Z',
+ },
+ }),
+ {
+ ...makeUserEntry('inspect the project', userId),
+ cwd: workDir,
+ sessionId,
+ },
+ makeAssistantEntry('No files needed changes.', 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
+ }
+ }>
+ }
+
+ expect(body.checkpoints).toHaveLength(1)
+ expect(body.checkpoints[0]).toMatchObject({
+ target: { targetUserMessageId: userId },
+ code: {
+ available: true,
+ filesChanged: [],
+ insertions: 0,
+ deletions: 0,
+ },
+ })
+ })
+
+ it('GET /api/sessions/:id/turn-checkpoints should retain transcript changes when the snapshot diff is empty', async () => {
+ const sessionId = '99999999-bbbb-cccc-dddd-000000000006'
+ const workDir = path.join(tmpDir, 'empty-snapshot-transcript-session')
+ const unchangedFile = path.join(workDir, 'src', 'unchanged.ts')
+ const transcriptFile = path.join(workDir, 'test123.md')
+ const userId = crypto.randomUUID()
+ const backupName = 'empty-snapshot-transcript@v1'
+ const content = 'export const unchanged = true\n'
+
+ await fs.mkdir(path.dirname(unchangedFile), { recursive: true })
+ await fs.writeFile(unchangedFile, content, 'utf-8')
+ await writeFileHistoryBackup(sessionId, backupName, content)
+ await writeSessionFile('-tmp-empty-snapshot-transcript-session', sessionId, [
+ makeSessionMetaEntry(workDir),
+ makeFileHistorySnapshotEntry(userId, {
+ 'src/unchanged.ts': {
+ backupFileName: backupName,
+ version: 1,
+ backupTime: '2026-01-01T00:00:00.000Z',
+ },
+ }),
+ {
+ ...makeUserEntry('write a short note', userId),
+ cwd: workDir,
+ sessionId,
+ },
+ makeAssistantToolUseEntry([{
+ id: 'Write:empty-snapshot-fallback',
+ name: 'Write',
+ input: {
+ file_path: transcriptFile,
+ content: '# Notes\n',
+ },
+ }], userId),
+ makeAssistantEntry('Note written.', userId),
+ ])
+
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
+ expect(res.status).toBe(200)
+ const body = await res.json() as {
+ checkpoints: Array<{
+ code: {
+ available: boolean
+ filesChanged: string[]
+ insertions: number
+ deletions: number
+ }
+ }>
+ }
+
+ expect(body.checkpoints).toHaveLength(1)
+ expect(body.checkpoints[0]!.code).toEqual({
+ available: true,
+ filesChanged: [transcriptFile],
+ insertions: 1,
+ deletions: 0,
+ })
+ })
+
it('GET /api/sessions/:id/turn-checkpoints/diff should return target-bound checkpoint diffs', async () => {
const fixture = await createThreeTurnCheckpointFixture(
'99999999-bbbb-cccc-dddd-ffffffffffff',
diff --git a/src/server/services/sessionRewindService.ts b/src/server/services/sessionRewindService.ts
index 8f523ee67d..4f7cbf0854 100644
--- a/src/server/services/sessionRewindService.ts
+++ b/src/server/services/sessionRewindService.ts
@@ -955,15 +955,19 @@ export async function listSessionTurnCheckpoints(
const checkpointPreview = targetSnapshot
? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot)
: null
- const preview = checkpointPreview?.available && checkpointPreview.filesChanged.length > 0
- ? checkpointPreview
- : buildTranscriptTurnCodePreview(
- activeMessages,
- target.targetUserMessageId,
- checkpointBaseDir,
- )
-
- if (!preview.available || preview.filesChanged.length === 0) continue
+ let preview = checkpointPreview
+ if (!preview?.available || preview.filesChanged.length === 0) {
+ const transcriptPreview = buildTranscriptTurnCodePreview(
+ activeMessages,
+ target.targetUserMessageId,
+ checkpointBaseDir,
+ )
+ if (transcriptPreview.available) {
+ preview = transcriptPreview
+ }
+ }
+
+ if (!preview?.available) continue
checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir))
}