Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions desktop/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -5283,7 +5283,7 @@ describe('MessageList nested tool calls', () => {
{
id: 'assistant-1',
type: 'assistant_text',
content: 'done',
content: '我正准备查看 test123.md',
timestamp: 2,
},
]
Expand All @@ -5297,6 +5297,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)

expect(await screen.findByText('first.ts')).toBeTruthy()
expect(screen.queryByText('Markdown')).toBeNull()

act(() => {
useChatStore.setState({
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -5604,7 +5607,7 @@ describe('MessageList nested tool calls', () => {
{
id: 'assistant-2',
type: 'assistant_text',
content: 'second done',
content: '我正准备查看 test123.md',
timestamp: 4,
},
],
Expand All @@ -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', () => {
Expand Down
11 changes: 5 additions & 6 deletions desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ function buildTurnCardInsertionMap(

const cardsByRenderIndex = new Map<number, TurnChangeCardModel[]>()
turnChangeCards.forEach((card) => {
if (card.checkpoint.code.filesChanged.length === 0) return
const renderIndex =
lastResponseIndexByTurnId.get(card.target.messageId) ??
userIndexByTurnId.get(card.target.messageId)
Expand Down Expand Up @@ -820,9 +821,7 @@ function buildChangedFilesByRenderIndex(
): Map<number, string[]> {
const filesByTurnId = new Map<string, string[]>()
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()

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 [{
Expand Down
19 changes: 16 additions & 3 deletions desktop/src/lib/assistantOutputTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }])
})

Expand Down
5 changes: 3 additions & 2 deletions desktop/src/lib/assistantOutputTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down Expand Up @@ -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)
}

Expand Down
113 changes: 113 additions & 0 deletions src/server/__tests__/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 13 additions & 9 deletions src/server/services/sessionRewindService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Loading