fix: keep workspace changes and session checkpoints reliable

The desktop workspace panel and chat transcript were mixing project-level file state with per-turn session history, which made rewind, file attachment, and changed-file previews brittle across multi-turn and historical sessions. This keeps turn checkpoints durable in the transcript, makes workspace refreshes happen at the right lifecycle points, and hardens long file previews without blocking the UI.

Constraint: Right-side workspace changes are project working-tree state, while chat turn cards are session checkpoint state.
Rejected: Treat every changed-file panel entry as session-local | new sessions must still reveal existing dirty project files.
Confidence: high
Scope-risk: moderate
Directive: Do not couple global workspace status to session checkpoint cards without preserving both product meanings.
Tested: cd desktop && bun run test src/components/workspace/WorkspacePanel.test.tsx
Tested: cd desktop && bun run lint
Tested: git diff --check
Tested: cd desktop && bun run test
Not-tested: Packaged Tauri runtime smoke test
This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 20:53:04 +08:00
parent e6378af234
commit f5cf5932b4
21 changed files with 2257 additions and 574 deletions

View File

@ -201,6 +201,22 @@ export type WorkspaceDiffResult = {
error?: string
}
export type SessionTurnCheckpoint = {
target: SessionRewindResponse['target']
conversation?: SessionRewindResponse['conversation']
code: SessionRewindResponse['code']
workDir?: string
}
export type SessionTurnCheckpointsResponse = {
checkpoints: SessionTurnCheckpoint[]
}
export type TurnCheckpointDiffResult = WorkspaceDiffResult & {
target?: SessionRewindResponse['target']
workDir?: string
}
function buildWorkspacePath(
sessionId: string,
resource: 'status' | 'tree' | 'file' | 'diff',
@ -279,6 +295,19 @@ export const sessionsApi = {
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
},
getTurnCheckpoints(sessionId: string) {
return api.get<SessionTurnCheckpointsResponse>(`/api/sessions/${sessionId}/turn-checkpoints`)
},
getTurnCheckpointDiff(sessionId: string, targetUserMessageId: string, workspacePath: string) {
const query = new URLSearchParams()
query.set('targetUserMessageId', targetUserMessageId)
query.set('path', workspacePath)
return api.get<TurnCheckpointDiffResult>(
`/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`,
)
},
rewind(sessionId: string, body: {
targetUserMessageId?: string
userMessageIndex?: number

View File

@ -0,0 +1,158 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const mocks = vi.hoisted(() => ({
getGitInfo: vi.fn(),
search: vi.fn(),
browse: vi.fn(),
wsSend: vi.fn(),
}))
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getGitInfo: mocks.getGitInfo,
},
}))
vi.mock('../../api/filesystem', () => ({
filesystemApi: {
search: mocks.search,
browse: mocks.browse,
},
}))
vi.mock('../../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
onMessage: vi.fn(() => () => {}),
clearHandlers: vi.fn(),
send: mocks.wsSend,
},
}))
vi.mock('../controls/PermissionModeSelector', () => ({
PermissionModeSelector: () => <button type="button">Permissions</button>,
}))
vi.mock('../controls/ModelSelector', () => ({
ModelSelector: () => <button type="button">Model</button>,
}))
import { ChatInput } from './ChatInput'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
describe('ChatInput file mentions', () => {
const sessionId = 'session-file-mention'
const initialChatState = useChatStore.getInitialState()
const initialSessionState = useSessionStore.getInitialState()
const initialTabState = useTabStore.getInitialState()
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useChatStore.setState(initialChatState, true)
useSessionStore.setState(initialSessionState, true)
useTabStore.setState(initialTabState, true)
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
useTabStore.setState({
activeTabId: sessionId,
tabs: [{ sessionId, title: 'Project', type: 'session', status: 'idle' }],
})
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Project',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 1,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
}],
activeSessionId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
})
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo/backend/src',
parentPath: '/repo/backend',
query: 'conditions.py',
entries: [
{ name: 'conditions.py', path: '/repo/backend/src/conditions.py', isDirectory: false },
],
})
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
const mention = '@backend/src/conditions.py'
fireEvent.change(input, {
target: {
value: `${mention} 记一下这个文件讲了什么东西。`,
selectionStart: mention.length,
},
})
fireEvent.click(await screen.findByText('conditions.py'))
await waitFor(() => {
expect(input.value).toBe('记一下这个文件讲了什么东西。')
})
expect(screen.getByText('conditions.py')).toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: '记一下这个文件讲了什么东西。',
attachments: [{
type: 'file',
name: 'conditions.py',
path: '/repo/backend/src/conditions.py',
lineStart: undefined,
lineEnd: undefined,
note: undefined,
quote: undefined,
}],
})
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
expect(messages[messages.length - 1]).toMatchObject({
type: 'user_text',
content: '记一下这个文件讲了什么东西。',
modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。',
attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }],
})
})
})

View File

@ -310,7 +310,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
// Extract filter text after @
const filter = textBeforeCursor.slice(pos + 1)
setAtFilter(filter)
setAtCursorPos(cursorPos)
setAtCursorPos(pos)
setSlashMenuOpen(false)
setFileSearchOpen(true)
}, [])
@ -629,17 +629,34 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
ref={fileSearchRef}
cwd={resolvedWorkDir || ''}
filter={atFilter}
onNavigate={(relativePath) => {
if (atCursorPos < 0) return
const replacement = `@${relativePath}`
const tokenEnd = atCursorPos + 1 + atFilter.length
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
const newCursorPos = atCursorPos + replacement.length
setInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}}
onSelect={(path, name) => {
if (atCursorPos >= 0) {
// Insert name at cursor position, replacing filter text
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
const newCursorPos = atCursorPos + name.length
const referenceName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const beforeToken = input.slice(0, atCursorPos)
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
const newValue = `${beforeToken}${spacer}${afterToken}`
const newCursorPos = atCursorPos + spacer.length
if (activeTabId) {
addWorkspaceReference(activeTabId, {
kind: 'file',
path,
absolutePath: path,
name,
name: referenceName,
})
}
setInput(newValue)

View File

@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
@ -11,48 +11,60 @@ type DiffPreviewState = {
type CurrentTurnChangeCardProps = {
sessionId: string
preview: SessionRewindResponse
targetUserMessageId: string
checkpoint: SessionTurnCheckpoint
workDir: string | null
error: string | null
isUndoing: boolean
isLatest: boolean
onUndo: () => void
}
type ChangedFileEntry = {
apiPath: string
displayPath: string
}
export function CurrentTurnChangeCard({
sessionId,
preview,
targetUserMessageId,
checkpoint,
workDir,
error,
isUndoing,
isLatest,
onUndo,
}: CurrentTurnChangeCardProps) {
const t = useTranslation()
const [expandedPath, setExpandedPath] = useState<string | null>(null)
const [diffByPath, setDiffByPath] = useState<Record<string, DiffPreviewState>>({})
const files = useMemo(
() => preview.code.filesChanged.map((filePath) => relativizeWorkspacePath(filePath, workDir)),
[preview.code.filesChanged, workDir],
const files = useMemo<ChangedFileEntry[]>(
() => checkpoint.code.filesChanged.map((filePath) => ({
apiPath: filePath,
displayPath: relativizeWorkspacePath(filePath, workDir),
})),
[checkpoint.code.filesChanged, workDir],
)
const toggleDiff = useCallback((filePath: string) => {
const nextExpandedPath = expandedPath === filePath ? null : filePath
const toggleDiff = useCallback((fileEntry: ChangedFileEntry) => {
const nextExpandedPath = expandedPath === fileEntry.apiPath ? null : fileEntry.apiPath
setExpandedPath(nextExpandedPath)
if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) {
if (!nextExpandedPath || diffByPath[fileEntry.apiPath]?.diff || diffByPath[fileEntry.apiPath]?.loading) {
return
}
setDiffByPath((current) => ({
...current,
[filePath]: { loading: true },
[fileEntry.apiPath]: { loading: true },
}))
void sessionsApi
.getWorkspaceDiff(sessionId, filePath)
.getTurnCheckpointDiff(sessionId, targetUserMessageId, fileEntry.apiPath)
.then((result) => {
setDiffByPath((current) => ({
...current,
[filePath]: {
[fileEntry.apiPath]: {
loading: false,
diff: result.state === 'ok' ? result.diff || '' : undefined,
error: result.state === 'ok'
@ -64,7 +76,7 @@ export function CurrentTurnChangeCard({
.catch((diffError) => {
setDiffByPath((current) => ({
...current,
[filePath]: {
[fileEntry.apiPath]: {
loading: false,
error: diffError instanceof Error
? diffError.message
@ -72,12 +84,25 @@ export function CurrentTurnChangeCard({
},
}))
})
}, [diffByPath, expandedPath, sessionId, t])
}, [diffByPath, expandedPath, sessionId, t, targetUserMessageId])
const cardLabel = isLatest
? t('chat.turnChangesLatestCardLabel')
: t('chat.turnChangesHistoricalCardLabel')
const subtitle = isLatest
? t('chat.turnChangesLatestSubtitle')
: t('chat.turnChangesHistoricalSubtitle')
const undoLabel = isLatest
? t('chat.turnChangesLatestUndo')
: t('chat.turnChangesHistoricalUndo')
const undoAria = isLatest
? t('chat.turnChangesLatestUndoAria')
: t('chat.turnChangesHistoricalUndoAria')
return (
<section
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
aria-label={t('chat.turnChangesCardLabel')}
aria-label={cardLabel}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
@ -86,14 +111,14 @@ export function CurrentTurnChangeCard({
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
+{preview.code.insertions}
+{checkpoint.code.insertions}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
-{preview.code.deletions}
-{checkpoint.code.deletions}
</span>
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesSubtitle')}
{subtitle}
</div>
</div>
@ -101,26 +126,26 @@ export function CurrentTurnChangeCard({
type="button"
onClick={onUndo}
disabled={isUndoing}
aria-label={t('chat.turnChangesUndoAria')}
aria-label={undoAria}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[15px]">undo</span>
{isUndoing ? t('chat.turnChangesUndoing') : t('chat.turnChangesUndo')}
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
</button>
</div>
<div className="divide-y divide-[var(--color-border)]">
{files.map((filePath) => {
const isExpanded = expandedPath === filePath
const diffState = diffByPath[filePath]
{files.map((fileEntry) => {
const isExpanded = expandedPath === fileEntry.apiPath
const diffState = diffByPath[fileEntry.apiPath]
return (
<div key={filePath}>
<div key={fileEntry.apiPath}>
<button
type="button"
onClick={() => toggleDiff(filePath)}
onClick={() => toggleDiff(fileEntry)}
aria-label={t(
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: filePath },
{ path: fileEntry.displayPath },
)}
className="flex min-h-11 w-full items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
>
@ -128,7 +153,7 @@ export function CurrentTurnChangeCard({
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{filePath}
{fileEntry.displayPath}
</span>
</button>
@ -145,7 +170,7 @@ export function CurrentTurnChangeCard({
) : diffState?.diff ? (
<WorkspaceDiffSurface
value={diffState.diff}
path={filePath}
path={fileEntry.displayPath}
className="max-h-[430px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]"
/>
) : (

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { FileSearchMenu } from './FileSearchMenu'
import { ApiError } from '../../api/client'
@ -55,4 +55,65 @@ describe('FileSearchMenu', () => {
expect(screen.getByText('preview.png')).toBeInTheDocument()
})
})
it('navigates directories without selecting them as attachments', async () => {
const onSelect = vi.fn()
const onNavigate = vi.fn()
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'backend',
entries: [
{ name: 'backend', path: '/repo/backend', isDirectory: true },
],
})
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
currentPath: '/repo/backend',
parentPath: '/repo',
entries: [
{ name: 'src', path: '/repo/backend/src', isDirectory: true },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="backend"
onSelect={onSelect}
onNavigate={onNavigate}
/>,
)
fireEvent.click(await screen.findByText('backend'))
expect(onSelect).not.toHaveBeenCalled()
expect(onNavigate).toHaveBeenCalledWith('backend/')
await waitFor(() => {
expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true })
})
})
it('passes nested relative file paths when selecting a file', async () => {
const onSelect = vi.fn()
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo/backend/src',
parentPath: '/repo/backend',
query: 'pictactic',
entries: [
{ name: 'pictactic', path: '/repo/backend/src/pictactic', isDirectory: false },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="backend/src/pictactic"
onSelect={onSelect}
/>,
)
fireEvent.click(await screen.findByText('pictactic'))
expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic')
})
})

View File

@ -18,9 +18,14 @@ type Props = {
cwd: string
filter?: string
onSelect: (path: string, relativePath: string) => void
onNavigate?: (relativePath: string) => void
}
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect }, ref) => {
function joinRelativePath(base: string, name: string) {
return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/')
}
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect, onNavigate }, ref) => {
const t = useTranslation()
const [entries, setEntries] = useState<DirEntry[]>([])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@ -118,13 +123,19 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
if (entries[selectedIndex]) {
onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name)
const selected = entries[selectedIndex]
if (selected) {
if (selected.isDirectory) {
void loadDir(selected.path, '')
onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)}/`)
} else {
onSelect(selected.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name))
}
}
return
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, selectedIndex])
}, [entries, selectedIndex, filter, loadDir, onNavigate, onSelect])
useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown])
@ -185,7 +196,8 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
key={entry.path}
data-index={i}
onClick={() => {
void loadDir(entry.path, filter)
void loadDir(entry.path, '')
onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name)}/`)
}}
onMouseEnter={() => setSelectedIndex(i)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
@ -204,7 +216,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<button
key={entry.path}
data-index={idx}
onClick={() => onSelect(entry.path, entry.name)}
onClick={() => onSelect(entry.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name))}
onMouseEnter={() => setSelectedIndex(idx)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
selectedIndex === idx ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'

View File

@ -39,6 +39,17 @@ describe('MessageList nested tool calls', () => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
() => new Promise(() => {}),
)
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
})
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
@ -564,29 +575,22 @@ describe('MessageList nested tool calls', () => {
})
it('does not expose the old message-level rewind action', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: ['src/App.tsx'],
insertions: 4,
deletions: 1,
},
})
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
code: {
available: true,
filesChanged: ['src/App.tsx'],
insertions: 4,
deletions: 1,
},
},
],
})
useChatStore.setState({
@ -616,30 +620,83 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
})
it('shows a current-turn change card from checkpoint preview', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: ['src/App.tsx', 'src/lib/api.ts'],
insertions: 12,
deletions: 4,
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never)
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '继续优化 workflow.py',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '两个文件均已优化完成,功能保持不变。',
timestamp: 2,
},
],
}),
},
})
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
render(<MessageList />)
expect(await screen.findByText('两个文件均已优化完成,功能保持不变。')).toBeTruthy()
await waitFor(() => {
expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalled()
})
expect(screen.queryByText(/Cannot read properties/)).toBeNull()
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
})
it('renders multiple historical turn change cards across three turns', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 3,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 3,
deletions: 1,
},
},
{
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 3,
},
code: {
available: true,
filesChanged: ['src/second.ts'],
insertions: 5,
deletions: 2,
},
},
{
target: {
targetUserMessageId: 'user-3',
userMessageIndex: 2,
userMessageCount: 3,
},
code: {
available: true,
filesChanged: [],
insertions: 0,
deletions: 0,
},
},
],
})
useChatStore.setState({
@ -662,7 +719,102 @@ describe('MessageList nested tool calls', () => {
id: 'user-2',
type: 'user_text',
content: '第二段',
modelContent: '@"/tmp/example-project/src/App.tsx" 第二段',
timestamp: 3,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'done',
timestamp: 4,
},
{
id: 'user-3',
type: 'user_text',
content: '第三段',
timestamp: 5,
},
{
id: 'assistant-3',
type: 'assistant_text',
content: 'done',
timestamp: 6,
},
],
}),
},
})
render(<MessageList />)
const cards = await screen.findAllByLabelText('Turn changed files')
expect(cards).toHaveLength(2)
expect(screen.getByText('src/first.ts')).toBeTruthy()
expect(screen.getByText('src/second.ts')).toBeTruthy()
expect(screen.queryByText('src/third.ts')).toBeNull()
})
it('expands a historical turn diff through the turn checkpoint diff API', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 1,
deletions: 1,
},
},
{
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: ['src/second.ts'],
insertions: 2,
deletions: 0,
},
},
],
})
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
state: 'ok',
path: 'src/first.ts',
diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new',
})
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
state: 'ok',
path: 'src/first.ts',
diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new',
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '第一轮',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'done',
timestamp: 2,
},
{
id: 'user-2',
type: 'user_text',
content: '第二轮',
timestamp: 3,
},
{
@ -678,49 +830,49 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
expect(await screen.findByText('2 files changed')).toBeTruthy()
expect(screen.getByLabelText('Current turn changed files').className).toContain('w-full max-w-[860px]')
expect(screen.getByText('+12')).toBeTruthy()
expect(screen.getByText('-4')).toBeTruthy()
expect(screen.getByText('src/App.tsx')).toBeTruthy()
expect(screen.getByText('src/lib/api.ts')).toBeTruthy()
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '@"/tmp/example-project/src/App.tsx" 第二段',
dryRun: true,
})
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
const diffSurface = await screen.findByTestId('workspace-code')
expect(diffSurface.textContent).toContain('+new')
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
ACTIVE_TAB,
'user-1',
'src/first.ts',
)
expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled()
})
it('expands a current-turn changed file diff', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: ['src/App.tsx'],
insertions: 1,
deletions: 1,
},
})
it('keeps checkpoint paths bound to the original turn cwd when expanding historical diffs', async () => {
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
workDir: '/tmp/current-project',
repoName: 'current-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 2,
},
workDir: '/tmp/old-project',
code: {
available: true,
filesChanged: ['/tmp/old-project/src/first.ts'],
insertions: 1,
deletions: 1,
},
},
],
})
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
state: 'ok',
path: 'src/App.tsx',
diff: 'diff --session a/src/App.tsx b/src/App.tsx\n-old\n+new',
path: '/tmp/old-project/src/first.ts',
diff: 'diff --git a/src/first.ts b/src/first.ts\n-old\n+new',
})
useChatStore.setState({
@ -730,7 +882,7 @@ describe('MessageList nested tool calls', () => {
{
id: 'user-1',
type: 'user_text',
content: '改一下',
content: '第一轮',
timestamp: 1,
},
{
@ -746,14 +898,107 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
const diffSurface = await screen.findByTestId('workspace-code')
expect(diffSurface.textContent).toContain('+new')
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
await screen.findByTestId('workspace-code')
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
ACTIVE_TAB,
'user-1',
'/tmp/old-project/src/first.ts',
)
})
it('confirms before undoing the current turn from the change card', async () => {
it('keeps historical turn change cards visible while the next turn is running', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 1,
deletions: 0,
},
},
],
})
const messages: UIMessage[] = [
{
id: 'user-1',
type: 'user_text',
content: '第一轮',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'done',
timestamp: 2,
},
]
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({ messages }),
},
})
render(<MessageList />)
expect(await screen.findByText('src/first.ts')).toBeTruthy()
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages,
chatState: 'thinking',
}),
},
})
})
await waitFor(() => {
expect(screen.getByText('src/first.ts')).toBeTruthy()
})
})
it('confirms before rewinding to an earlier turn from a historical change card', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 1,
deletions: 0,
},
},
{
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: ['src/second.ts'],
insertions: 1,
deletions: 0,
},
},
],
})
vi.spyOn(sessionsApi, 'rewind')
.mockResolvedValueOnce({
target: {
@ -788,14 +1033,6 @@ describe('MessageList nested tool calls', () => {
deletions: 0,
},
})
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()
@ -811,99 +1048,6 @@ describe('MessageList nested tool calls', () => {
content: '做一个页面',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'done',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' }))
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
expect(within(dialog).getByText('This will rewind the latest assistant response and restore tracked files for this turn.')).toBeTruthy()
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
await waitFor(() => {
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
expectedContent: '做一个页面',
})
})
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
text: '做一个页面',
attachments: undefined,
})
})
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',
@ -929,49 +1073,68 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
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,
})
const historicalCard = (await screen.findByText('src/first.ts')).closest('section')
expect(historicalCard).toBeTruthy()
fireEvent.click(
within(historicalCard as HTMLElement).getByRole('button', {
name: 'Rewind to before this turn',
}),
)
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' }))
expect(sessionsApi.rewind).not.toHaveBeenCalled()
const dialog = await screen.findByRole('dialog', { name: 'Rewind to before this turn?' })
expect(
within(dialog).getByText(
'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.',
),
).toBeTruthy()
fireEvent.click(within(dialog).getByRole('button', { name: 'Rewind to before this turn' }))
await waitFor(() => {
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '第二轮需求',
targetUserMessageId: 'user-1',
userMessageIndex: 0,
expectedContent: '做一个页面',
})
})
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
text: '第二轮需求',
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,
},
it('does not render cards for turns without file changes', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 2,
deletions: 1,
},
},
{
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
code: {
available: true,
filesChanged: [],
insertions: 0,
deletions: 0,
},
},
],
})
useChatStore.setState({
@ -1009,15 +1172,10 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
await waitFor(() => {
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '第二轮只解释',
dryRun: true,
})
})
expect(screen.queryByLabelText('Current turn changed files')).toBeNull()
const cards = await screen.findAllByLabelText('Turn changed files')
expect(cards).toHaveLength(1)
expect(screen.getByText('src/first.ts')).toBeTruthy()
expect(screen.queryByText('src/second.ts')).toBeNull()
})
it('shows raw startup details under translated CLI startup errors', () => {

View File

@ -1,6 +1,6 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
@ -42,10 +42,11 @@ type RewindTurnTarget = {
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type CurrentTurnPreview = {
type TurnChangeCardModel = {
target: RewindTurnTarget
preview: SessionRewindResponse
checkpoint: SessionTurnCheckpoint
workDir: string | null
isLatest: boolean
}
function appendChildToolCall(
@ -118,40 +119,117 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
return { renderItems: items, toolResultMap, childToolCallsByParent }
}
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
let userMessageIndex = -1
let latestTarget: (RewindTurnTarget & { messageOffset: number }) | null = null
function isTurnResponseMessage(message: UIMessage) {
return (
message.type === 'assistant_text' ||
message.type === 'tool_use' ||
message.type === 'tool_result' ||
message.type === 'error' ||
message.type === 'task_summary'
)
}
for (let messageOffset = 0; messageOffset < messages.length; messageOffset += 1) {
const message = messages[messageOffset]
if (!message || message.type !== 'user_text' || message.pending) continue
userMessageIndex += 1
latestTarget = {
messageId: message.id,
userMessageIndex,
content: message.content,
expectedContent: message.modelContent ?? message.content,
attachments: message.attachments,
messageOffset,
export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] {
let userMessageIndex = -1
const completedTurns: RewindTurnTarget[] = []
let currentTarget: RewindTurnTarget | null = null
let hasResponseForCurrentTarget = false
for (const message of messages) {
if (message.type === 'user_text' && !message.pending) {
if (currentTarget && hasResponseForCurrentTarget) {
completedTurns.push(currentTarget)
}
userMessageIndex += 1
currentTarget = {
messageId: message.id,
userMessageIndex,
content: message.content,
expectedContent: message.modelContent ?? message.content,
attachments: message.attachments,
}
hasResponseForCurrentTarget = false
continue
}
if (currentTarget && isTurnResponseMessage(message)) {
hasResponseForCurrentTarget = true
}
}
if (!latestTarget) return null
if (currentTarget && hasResponseForCurrentTarget) {
completedTurns.push(currentTarget)
}
const hasResponseAfterTarget = messages
.slice(latestTarget.messageOffset + 1)
.some((message) =>
message.type === 'assistant_text' ||
message.type === 'tool_use' ||
message.type === 'tool_result' ||
message.type === 'error' ||
message.type === 'task_summary',
)
return completedTurns
}
if (!hasResponseAfterTarget) return null
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
const completedTurns = getCompletedTurnTargets(messages)
return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null
}
const { messageOffset: _messageOffset, ...target } = latestTarget
return target
function buildTurnCardInsertionMap(
renderItems: RenderItem[],
turnChangeCards: TurnChangeCardModel[],
) {
const lastResponseIndexByTurnId = new Map<string, number>()
let activeTurnId: string | null = null
renderItems.forEach((item, index) => {
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
activeTurnId = item.message.id
return
}
if (activeTurnId) {
lastResponseIndexByTurnId.set(activeTurnId, index)
}
})
const cardsByRenderIndex = new Map<number, TurnChangeCardModel[]>()
turnChangeCards.forEach((card) => {
const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId)
if (renderIndex === undefined) return
const existing = cardsByRenderIndex.get(renderIndex)
if (existing) {
existing.push(card)
} else {
cardsByRenderIndex.set(renderIndex, [card])
}
})
return cardsByRenderIndex
}
function getApiErrorMessage(error: unknown) {
return error instanceof ApiError
? typeof error.body === 'object' && error.body && 'message' in error.body
? String((error.body as { message: unknown }).message)
: error.message
: error instanceof Error
? error.message
: String(error)
}
function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint {
if (!value || typeof value !== 'object') return false
const checkpoint = value as Partial<SessionTurnCheckpoint>
return (
Boolean(checkpoint.target) &&
typeof checkpoint.target?.targetUserMessageId === 'string' &&
typeof checkpoint.target?.userMessageIndex === 'number' &&
Boolean(checkpoint.code) &&
typeof checkpoint.code?.available === 'boolean' &&
Array.isArray(checkpoint.code?.filesChanged)
)
}
function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] {
if (!response || typeof response !== 'object') return []
const checkpoints = (response as { checkpoints?: unknown }).checkpoints
if (!Array.isArray(checkpoints)) return []
return checkpoints.filter(isSessionTurnCheckpoint)
}
type MessageListProps = {
@ -191,11 +269,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const shouldAutoScrollRef = useRef(true)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const t = useTranslation()
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false)
const [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false)
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const updateAutoScrollState = useCallback(() => {
const container = scrollContainerRef.current
@ -218,76 +297,90 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
: null
const turnCardsByRenderIndex = useMemo(
() => buildTurnCardInsertionMap(renderItems, turnChangeCards),
[renderItems, turnChangeCards],
)
const confirmTurnCard = useMemo(
() => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnChangeCards, turnUndoConfirmTargetId],
)
useEffect(() => {
if (
!resolvedSessionId ||
!latestTurnTarget ||
chatState !== 'idle' ||
isMemberSession
) {
setCurrentTurnPreview(null)
setCurrentTurnError(null)
setIsLoadingCurrentTurnPreview(false)
if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) {
setTurnChangeCards([])
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
return
}
if (chatState !== 'idle') {
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
return
}
let cancelled = false
setIsLoadingCurrentTurnPreview(true)
setCurrentTurnPreview(null)
setCurrentTurnError(null)
setIsLoadingTurnChangeCards(true)
setTurnChangeLoadError(null)
Promise.all([
sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: latestTurnTarget.messageId,
userMessageIndex: latestTurnTarget.userMessageIndex,
expectedContent: latestTurnTarget.expectedContent,
dryRun: true,
}),
sessionsApi.getTurnCheckpoints(resolvedSessionId),
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
])
.then(([preview, workspaceStatus]) => {
.then(([checkpointResponse, workspaceStatus]) => {
if (cancelled) return
if (!preview.code.available || preview.code.filesChanged.length === 0) {
setCurrentTurnPreview(null)
return
}
setCurrentTurnPreview({
target: latestTurnTarget,
preview,
workDir: workspaceStatus?.workDir ?? null,
})
const targetByMessageId = new Map(
completedTurnTargets.map((target) => [target.messageId, target] as const),
)
setTurnChangeCards(
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const target = targetByMessageId.get(checkpoint.target.targetUserMessageId)
if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) {
return []
}
return [{
target,
checkpoint,
workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null,
isLatest: target.messageId === latestCompletedTurnId,
}]
}),
)
})
.catch((error) => {
if (cancelled) return
const message =
error instanceof ApiError
? typeof error.body === 'object' && error.body && 'message' in error.body
? String((error.body as { message: unknown }).message)
: error.message
: error instanceof Error
? error.message
: String(error)
setCurrentTurnError(message)
setTurnChangeCards([])
setTurnChangeLoadError(getApiErrorMessage(error))
})
.finally(() => {
if (!cancelled) {
setIsLoadingCurrentTurnPreview(false)
setIsLoadingTurnChangeCards(false)
}
})
return () => {
cancelled = true
}
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
}, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId])
const handleUndoCurrentTurn = useCallback(async () => {
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return
const target = currentTurnPreview.target
setIsUndoingCurrentTurn(true)
setCurrentTurnError(null)
const target = confirmTurnCard.target
setRewindingTurnId(target.messageId)
setTurnActionErrors((current) => {
if (!(target.messageId in current)) return current
const next = { ...current }
delete next[target.messageId]
return next
})
try {
if (chatState !== 'idle') {
@ -317,30 +410,24 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}),
})
setCurrentTurnPreview(null)
setCurrentTurnUndoConfirmOpen(false)
setTurnUndoConfirmTargetId(null)
} catch (error) {
const message =
error instanceof ApiError
? typeof error.body === 'object' && error.body && 'message' in error.body
? String((error.body as { message: unknown }).message)
: error.message
: error instanceof Error
? error.message
: String(error)
setCurrentTurnError(message)
setCurrentTurnUndoConfirmOpen(false)
setTurnActionErrors((current) => ({
...current,
[target.messageId]: getApiErrorMessage(error),
}))
setTurnUndoConfirmTargetId(null)
} finally {
setIsUndoingCurrentTurn(false)
setRewindingTurnId(null)
}
}, [
addToast,
chatState,
currentTurnPreview,
isUndoingCurrentTurn,
confirmTurnCard,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
rewindingTurnId,
stopGeneration,
t,
])
@ -352,39 +439,54 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
<ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
)
}
{renderItems.map((item, index) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
const msg = item.message
return (
<MessageBlock
key={msg.id}
message={msg}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
msg.type === 'tool_use'
? (() => {
const r = toolResultMap.get(msg.toolUseId)
return r ? { content: r.content, isError: r.isError } : null
})()
: null
}
/>
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
const result = toolResultMap.get(item.message.toolUseId)
return result ? { content: result.content, isError: result.isError } : null
})()
: null
}
/>
)}
{resolvedSessionId && cardsForItem.map((card) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.target.messageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
</div>
)
})}
@ -400,22 +502,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<StreamingIndicator />
)}
{!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && (
<CurrentTurnChangeCard
sessionId={resolvedSessionId}
preview={currentTurnPreview.preview}
workDir={currentTurnPreview.workDir}
error={currentTurnError}
isUndoing={isUndoingCurrentTurn}
onUndo={() => {
setCurrentTurnUndoConfirmOpen(true)
}}
/>
)}
{!currentTurnPreview && currentTurnError && (
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{currentTurnError}
{turnChangeLoadError}
</div>
)}
@ -423,19 +512,25 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
</div>
<ConfirmDialog
open={currentTurnUndoConfirmOpen}
open={Boolean(confirmTurnCard)}
onClose={() => {
if (!isUndoingCurrentTurn) {
setCurrentTurnUndoConfirmOpen(false)
if (!rewindingTurnId) {
setTurnUndoConfirmTargetId(null)
}
}}
onConfirm={handleUndoCurrentTurn}
title={t('chat.turnChangesConfirmTitle')}
body={t('chat.turnChangesConfirmBody')}
confirmLabel={t('chat.turnChangesConfirmUndo')}
title={confirmTurnCard?.isLatest
? t('chat.turnChangesLatestConfirmTitle')
: t('chat.turnChangesHistoricalConfirmTitle')}
body={confirmTurnCard?.isLatest
? t('chat.turnChangesLatestConfirmBody')
: t('chat.turnChangesHistoricalConfirmBody')}
confirmLabel={confirmTurnCard?.isLatest
? t('chat.turnChangesLatestConfirmUndo')
: t('chat.turnChangesHistoricalConfirmUndo')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUndoingCurrentTurn}
loading={Boolean(rewindingTurnId)}
/>
</div>
)

View File

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { useTranslation } from '../../i18n'
export const WORKSPACE_PREVIEW_LINE_LIMIT = 420
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
export const workspacePrismTheme: PrismTheme = {
plain: {
@ -95,10 +97,15 @@ export function WorkspaceDiffSurface({
lineLimit?: number
}) {
const t = useTranslation()
const [showAllLines, setShowAllLines] = useState(false)
const lines = value.split('\n')
const visibleLines = lines.slice(0, lineLimit)
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
const language = getLanguageFromPath(path)
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
useEffect(() => {
setShowAllLines(false)
}, [path, value])
return (
<div className={className}>
@ -119,7 +126,7 @@ export function WorkspaceDiffSurface({
return (
<div
key={`${index}:${line}`}
key={index}
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
isAdded
? 'bg-[var(--color-diff-added-bg)]'
@ -153,7 +160,7 @@ export function WorkspaceDiffSurface({
: ''
}`}
>
{isCodeLine ? (
{isCodeLine && !usePlainLargePreview ? (
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
) : (
code || ' '
@ -163,9 +170,20 @@ export function WorkspaceDiffSurface({
)
})}
</pre>
{hiddenLineCount > 0 && (
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
{t('workspace.previewLineLimit', { count: lineLimit })}
{lines.length > lineLimit && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
<span>
{showAllLines
? t('workspace.previewAllLines', { total: lines.length })
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
</span>
<button
type="button"
onClick={() => setShowAllLines((current) => !current)}
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>

View File

@ -120,6 +120,7 @@ vi.mock('../../api/sessions', () => ({
}))
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { WorkspacePanel } from './WorkspacePanel'
@ -128,17 +129,38 @@ describe('WorkspacePanel', () => {
const workspaceInitialState = useWorkspacePanelStore.getInitialState()
const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState()
const settingsInitialState = useSettingsStore.getInitialState()
const chatInitialState = useChatStore.getInitialState()
beforeEach(async () => {
vi.clearAllMocks()
await setWorkspaceState(workspaceInitialState)
useChatStore.setState(chatInitialState, true)
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
await setSettingsState({ ...settingsInitialState, locale: 'en' })
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) =>
useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
)
getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') =>
useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? {
state: 'ok',
path,
entries: [],
},
)
})
afterEach(async () => {
cleanup()
await setWorkspaceState(workspaceInitialState)
useChatStore.setState(chatInitialState, true)
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
await setSettingsState(settingsInitialState)
vi.restoreAllMocks()
@ -234,6 +256,101 @@ describe('WorkspacePanel', () => {
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/Fresh.ts',
status: 'modified',
additions: 4,
deletions: 1,
},
],
})
getMocks().getWorkspaceTreeMock.mockResolvedValue({
state: 'ok',
path: '',
entries: [{ name: 'src', path: 'src', isDirectory: true }],
})
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-stale-all': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-stale-all': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-stale-all')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all')
})
await waitFor(() => {
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
})
expect(view.getByText('src/Fresh.ts')).toBeTruthy()
})
it('loads workspace status when opened while the chat is running', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/running.ts',
status: 'modified',
additions: 1,
deletions: 0,
},
],
})
useChatStore.setState({
sessions: {
'session-running-open': {
chatState: 'thinking',
} as never,
},
})
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-running-open')
})
const view = await renderPanel('session-running-open')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open')
})
await waitFor(() => {
expect(view.getByText('src/running.ts')).toBeTruthy()
})
expect(view.queryByText('Loading...')).toBeNull()
})
it('renders transcript-derived changed files for non-git sessions', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
@ -459,6 +576,7 @@ describe('WorkspacePanel', () => {
'session-tabs': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -534,6 +652,7 @@ describe('WorkspacePanel', () => {
'session-dark-theme': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -579,8 +698,8 @@ describe('WorkspacePanel', () => {
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')
it('can expand long diff previews beyond the default rendered line cap', async () => {
const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n')
await setWorkspaceState((state) => ({
...state,
@ -589,6 +708,7 @@ describe('WorkspacePanel', () => {
'session-large-preview': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -623,9 +743,59 @@ describe('WorkspacePanel', () => {
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('+line 1')
expect(highlightedCode).toContain('+line 420')
expect(highlightedCode).not.toContain('+line 421')
expect(view.getByText('Showing first 420 lines. Open in your editor for the full file.')).toBeTruthy()
expect(highlightedCode).toContain('+line 2000')
expect(highlightedCode).not.toContain('+line 2001')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
})
it('can expand long file previews beyond the default rendered line cap', async () => {
const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n')
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-large-file-preview': {
isOpen: true,
activeView: 'all',
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-large-file-preview': [{
id: 'file:large-file.ts',
path: 'large-file.ts',
kind: 'file',
title: 'large-file.ts',
content: longFile,
language: 'typescript',
previewType: 'text',
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-large-file-preview': 'file:large-file.ts',
},
}))
const view = await renderPanel('session-large-file-preview')
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('const line1 = 1')
expect(highlightedCode).toContain('const line2000 = 2000')
expect(highlightedCode).not.toContain('const line2001 = 2001')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
})
it('renders image previews from workspace files', async () => {
@ -724,6 +894,7 @@ describe('WorkspacePanel', () => {
'session-preview-menu': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -922,6 +1093,7 @@ describe('WorkspacePanel', () => {
'session-zh': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -950,6 +1122,7 @@ describe('WorkspacePanel', () => {
'session-compact-header': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -987,6 +1160,7 @@ describe('WorkspacePanel', () => {
'session-empty': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
@ -1006,6 +1180,20 @@ describe('WorkspacePanel', () => {
expect(view.getByText('No changes')).toBeTruthy()
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => {
if (sessionId === 'session-error') {
throw new Error('status failed')
}
return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
}
})
await setWorkspaceState((state) => ({
...state,
panelBySession: {
@ -1013,6 +1201,7 @@ describe('WorkspacePanel', () => {
'session-error': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
errors: {
@ -1028,6 +1217,8 @@ describe('WorkspacePanel', () => {
view.rerender(<WorkspacePanel sessionId="session-error" />)
})
expect(view.getByText('status failed')).toBeTruthy()
await waitFor(() => {
expect(view.getByText('status failed')).toBeTruthy()
})
})
})

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
import { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
@ -14,11 +14,13 @@ import {
type WorkspacePreviewKind,
type WorkspacePreviewTab,
} from '../../stores/workspacePanelStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import {
getFileExtension,
normalizePrismLanguage,
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface,
workspacePrismTheme,
@ -315,11 +317,18 @@ function CodeSurface({
const t = useTranslation()
const [commentLine, setCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const [showAllLines, setShowAllLines] = useState(false)
const lines = value.split('\n')
const visibleLines = lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
const visibleCode = visibleLines.join('\n')
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n')
useEffect(() => {
setShowAllLines(false)
setCommentLine(null)
setCommentDraft('')
}, [language, value])
const submitLineComment = () => {
if (!commentLine || !commentDraft.trim()) return
@ -328,99 +337,142 @@ function CodeSurface({
setCommentDraft('')
}
const renderLineCommentEditor = (lineNumber: number) => {
if (commentLine !== lineNumber) return null
return (
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
<span aria-hidden="true" />
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
{t('workspace.commentLineTarget', { line: lineNumber })}
</span>
</div>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
autoFocus
rows={3}
placeholder={t('workspace.commentPlaceholder')}
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
<div className="flex justify-end gap-2 px-3 pb-3">
<button
type="button"
onClick={() => {
setCommentLine(null)
setCommentDraft('')
}}
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={submitLineComment}
disabled={!commentDraft.trim()}
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
>
{t('workspace.addCommentToChat')}
</button>
</div>
</div>
</div>
)
}
const renderLineNumberButton = (lineNumber: number) => (
<button
type="button"
aria-label={t('workspace.commentLine', { line: lineNumber })}
onClick={() => {
setCommentLine(lineNumber)
setCommentDraft('')
}}
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
>
{lineNumber}
</button>
)
return (
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]">
<div className="relative min-w-max py-2">
<Highlight
theme={workspacePrismTheme}
code={visibleCode}
language={normalizePrismLanguage(language)}
>
{({ tokens, getLineProps, getTokenProps }) => (
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
>
{tokens.map((line, index) => {
const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
const lineNumber = index + 1
return (
<div key={String(lineKey)}>
<div
{...lineProps}
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
aria-label={t('workspace.commentLine', { line: lineNumber })}
onClick={() => {
setCommentLine(lineNumber)
setCommentDraft('')
}}
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
>
{lineNumber}
</button>
<span className="whitespace-pre pr-6">
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</span>
</div>
{commentLine === lineNumber && (
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
<span aria-hidden="true" />
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
{t('workspace.commentLineTarget', { line: lineNumber })}
</span>
</div>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
autoFocus
rows={3}
placeholder={t('workspace.commentPlaceholder')}
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
<div className="flex justify-end gap-2 px-3 pb-3">
<button
type="button"
onClick={() => {
setCommentLine(null)
setCommentDraft('')
}}
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={submitLineComment}
disabled={!commentDraft.trim()}
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
>
{t('workspace.addCommentToChat')}
</button>
</div>
</div>
</div>
)}
{usePlainLargePreview ? (
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
>
{visibleLines.map((line, index) => {
const lineNumber = index + 1
return (
<div key={lineNumber}>
<div className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]">
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">{line || ' '}</span>
</div>
)
})}
</pre>
)}
</Highlight>
{hiddenLineCount > 0 && (
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
{t('workspace.previewLineLimit', { count: WORKSPACE_PREVIEW_LINE_LIMIT })}
{renderLineCommentEditor(lineNumber)}
</div>
)
})}
</pre>
) : (
<Highlight
theme={workspacePrismTheme}
code={visibleCode}
language={normalizePrismLanguage(language)}
>
{({ tokens, getLineProps, getTokenProps }) => (
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
>
{tokens.map((line, index) => {
const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
const lineNumber = index + 1
return (
<div key={String(lineKey)}>
<div
{...lineProps}
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
>
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</span>
</div>
{renderLineCommentEditor(lineNumber)}
</div>
)
})}
</pre>
)}
</Highlight>
)}
{lines.length > WORKSPACE_PREVIEW_LINE_LIMIT && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
<span>
{showAllLines
? t('workspace.previewAllLines', { total: lines.length })
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
</span>
<button
type="button"
onClick={() => setShowAllLines((current) => !current)}
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>
@ -651,6 +703,12 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
const refreshLifecycleRef = useRef({
sessionId,
isOpen: false,
chatState: 'idle',
})
const rootTree = treeByPath['']
const rootTreeKey = makeTreeStateKey(sessionId, '')
@ -682,9 +740,22 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
)
useEffect(() => {
if (!isOpen || activeView !== 'changed' || status || statusLoading || statusError) return
const previous = refreshLifecycleRef.current
const sessionChanged = previous.sessionId !== sessionId
const opened = isOpen && (sessionChanged || !previous.isOpen)
const completedTurn =
isOpen &&
!sessionChanged &&
previous.chatState !== 'idle' &&
chatState === 'idle'
refreshLifecycleRef.current = { sessionId, isOpen, chatState }
const shouldRefreshOnOpen = opened
const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle'
if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return
void loadStatus(sessionId)
}, [activeView, isOpen, loadStatus, sessionId, status, statusError, statusLoading])
}, [chatState, isOpen, loadStatus, sessionId, statusLoading])
useEffect(() => {
if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return

View File

@ -67,7 +67,10 @@ export const en = {
'workspace.previewState.tooLarge': 'File is too large to preview.',
'workspace.previewState.missing': 'File not found.',
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
'workspace.previewLineLimit': 'Showing first {count} of {total} loaded lines.',
'workspace.previewAllLines': 'Showing all {total} loaded lines.',
'workspace.showAllLoadedLines': 'Show all loaded lines',
'workspace.collapsePreview': 'Collapse preview',
'workspace.addToChat': 'Add to chat',
'workspace.copyPath': 'Copy path',
'workspace.localComment': 'Local comment',
@ -648,15 +651,22 @@ export const en = {
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
'chat.turnChangesCardLabel': 'Current turn changed files',
'chat.turnChangesTitle': '{count} files changed',
'chat.turnChangesSubtitle': 'Current turn checkpoint',
'chat.turnChangesUndo': 'Undo',
'chat.turnChangesLatestCardLabel': 'Turn changed files',
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
'chat.turnChangesLatestSubtitle': 'Current turn checkpoint',
'chat.turnChangesHistoricalSubtitle': 'Saved turn checkpoint',
'chat.turnChangesLatestUndo': 'Undo current turn',
'chat.turnChangesHistoricalUndo': 'Rewind to before this turn',
'chat.turnChangesUndoing': 'Undoing...',
'chat.turnChangesUndoAria': 'Undo current turn changes',
'chat.turnChangesConfirmTitle': 'Undo current turn?',
'chat.turnChangesConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
'chat.turnChangesConfirmUndo': 'Undo current turn',
'chat.turnChangesLatestUndoAria': 'Undo current turn changes',
'chat.turnChangesHistoricalUndoAria': 'Rewind to before this turn',
'chat.turnChangesLatestConfirmTitle': 'Undo current turn?',
'chat.turnChangesHistoricalConfirmTitle': 'Rewind to before this turn?',
'chat.turnChangesLatestConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
'chat.turnChangesHistoricalConfirmBody': 'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.',
'chat.turnChangesLatestConfirmUndo': 'Undo current turn',
'chat.turnChangesHistoricalConfirmUndo': 'Rewind to before this turn',
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
'chat.turnChangesDiffLoading': 'Loading diff...',

View File

@ -69,7 +69,10 @@ export const zh: Record<TranslationKey, string> = {
'workspace.previewState.tooLarge': '文件过大,无法预览。',
'workspace.previewState.missing': '文件不存在。',
'workspace.imagePreviewUnavailable': '图片无法预览。',
'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。',
'workspace.previewLineLimit': '正在显示已加载内容的前 {count} / {total} 行。',
'workspace.previewAllLines': '正在显示全部 {total} 行已加载内容。',
'workspace.showAllLoadedLines': '显示全部已加载行',
'workspace.collapsePreview': '收起预览',
'workspace.addToChat': '添加到聊天',
'workspace.copyPath': '复制路径',
'workspace.localComment': '本地评论',
@ -650,15 +653,22 @@ export const zh: Record<TranslationKey, string> = {
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesCardLabel': '当前轮次已更改文件',
'chat.turnChangesTitle': '{count} 个文件已更改',
'chat.turnChangesSubtitle': '当前轮次检查点',
'chat.turnChangesUndo': '撤销',
'chat.turnChangesLatestCardLabel': '轮次已更改文件',
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
'chat.turnChangesLatestSubtitle': '当前轮次检查点',
'chat.turnChangesHistoricalSubtitle': '历史轮次检查点',
'chat.turnChangesLatestUndo': '撤销当前轮次',
'chat.turnChangesHistoricalUndo': '回滚到这一轮之前',
'chat.turnChangesUndoing': '正在撤销...',
'chat.turnChangesUndoAria': '撤销当前轮次变更',
'chat.turnChangesConfirmTitle': '撤销当前轮次?',
'chat.turnChangesConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
'chat.turnChangesConfirmUndo': '撤销当前轮次',
'chat.turnChangesLatestUndoAria': '撤销当前轮次变更',
'chat.turnChangesHistoricalUndoAria': '回滚到这一轮之前',
'chat.turnChangesLatestConfirmTitle': '撤销当前轮次?',
'chat.turnChangesHistoricalConfirmTitle': '回滚到这一轮之前?',
'chat.turnChangesLatestConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
'chat.turnChangesHistoricalConfirmBody': '这会把会话回滚到这一轮之前,并恢复该检查点对应的文件变更。',
'chat.turnChangesLatestConfirmUndo': '撤销当前轮次',
'chat.turnChangesHistoricalConfirmUndo': '回滚到这一轮之前',
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
'chat.turnChangesDiffLoading': '正在加载 diff...',

View File

@ -30,6 +30,7 @@ type Attachment = {
id: string
name: string
type: 'image' | 'file'
path?: string
mimeType?: string
previewUrl?: string
data?: string
@ -288,6 +289,7 @@ export function EmptySession() {
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
path: attachment.path,
data: attachment.data,
mimeType: attachment.mimeType,
}))
@ -336,7 +338,7 @@ export function EmptySession() {
setAtCursorPos(-1)
} else {
setAtFilter(textBeforeCursor.slice(pos + 1))
setAtCursorPos(cursorPos)
setAtCursorPos(pos)
setSlashMenuOpen(false)
setFileSearchOpen(true)
}
@ -530,10 +532,37 @@ export function EmptySession() {
ref={fileSearchRef}
cwd={workDir || ''}
filter={atFilter}
onSelect={(_path, name) => {
onNavigate={(relativePath) => {
if (atCursorPos < 0) return
const replacement = `@${relativePath}`
const tokenEnd = atCursorPos + 1 + atFilter.length
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
const newCursorPos = atCursorPos + replacement.length
setInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}}
onSelect={(path, name) => {
if (atCursorPos >= 0) {
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
const newCursorPos = atCursorPos + name.length
const attachmentName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const beforeToken = input.slice(0, atCursorPos)
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
const newValue = `${beforeToken}${spacer}${afterToken}`
const newCursorPos = atCursorPos + spacer.length
setAttachments((prev) => [
...prev,
{
id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: attachmentName,
type: 'file',
path,
},
])
setInput(newValue)
setFileSearchOpen(false)
setAtFilter('')

View File

@ -312,7 +312,7 @@ describe('chatStore history mapping', () => {
{
type: 'user_text',
content: '改这里',
modelContent: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
modelContent: '@"/repo/src/App.tsx" Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
attachments: [{
type: 'file',
name: 'App.tsx',
@ -342,6 +342,54 @@ describe('chatStore history mapping', () => {
)
})
it('stores server-materialized attachment prefixes for rewind matching', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().sendMessage(
TEST_SESSION_ID,
'记一下这个文件讲了什么东西。',
[{ type: 'file', name: 'conditions.py', path: '/repo/backend/conditions.py' }],
{
displayContent: '记一下这个文件讲了什么东西。',
displayAttachments: [{ type: 'file', name: 'conditions.py', path: 'backend/conditions.py' }],
},
)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
content: '记一下这个文件讲了什么东西。',
modelContent: '@"/repo/backend/conditions.py" 记一下这个文件讲了什么东西。',
attachments: [{
type: 'file',
name: 'conditions.py',
path: 'backend/conditions.py',
}],
},
])
})
it('keeps parent tool linkage for live tool events', () => {
// Initialize the session first
useChatStore.setState({

View File

@ -264,6 +264,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
sendMessage: (sessionId, content, attachments, options) => {
const userFacingContent =
options?.displayContent?.trim() || content.trim()
const modelFacingContent = buildModelContent(content, attachments)
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
const visibleAttachments = options?.displayAttachments ?? attachments
const uiAttachments: UIAttachment[] | undefined =
@ -315,7 +316,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
id: nextId(),
type: 'user_text',
content: userFacingContent,
...(userFacingContent !== content ? { modelContent: content } : {}),
...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}),
attachments: isMemberSession ? undefined : uiAttachments,
timestamp: Date.now(),
...(isMemberSession ? { pending: true } : {}),
@ -925,6 +926,16 @@ type HistoryMappingOptions = {
includeTeammateMessages?: boolean
}
function buildModelContent(content: string, attachments?: AttachmentRef[]): string {
const paths = attachments
?.map((attachment) => attachment.path)
.filter((path): path is string => typeof path === 'string' && path.length > 0) ?? []
const trimmed = content.trim()
if (paths.length === 0) return trimmed
const prefix = paths.map((path) => `@"${path}"`).join(' ')
return `${prefix} ${trimmed || 'Please analyze the attached files.'}`.trim()
}
function getReferenceName(referencePath: string): string {
const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '')
const name = normalized.split('/').filter(Boolean).pop()

View File

@ -162,6 +162,65 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
})
it('returns to changed-files when a refreshed default all-files view now has changes', async () => {
mocks.getWorkspaceStatusMock
.mockResolvedValueOnce({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
.mockResolvedValueOnce({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 2,
deletions: 1,
},
],
})
useWorkspacePanelStore.getState().openPanel('session-refresh-changes')
await useWorkspacePanelStore.getState().loadStatus('session-refresh-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-refresh-changes')).toBe('all')
await useWorkspacePanelStore.getState().loadStatus('session-refresh-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-refresh-changes')).toBe('changed')
})
it('does not override an explicit all-files selection when refreshed status has changes', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 2,
deletions: 1,
},
],
})
useWorkspacePanelStore.getState().openPanel('session-explicit-all')
useWorkspacePanelStore.getState().setActiveView('session-explicit-all', 'all')
await useWorkspacePanelStore.getState().loadStatus('session-explicit-all')
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-all')).toBe('all')
})
it('does not override an explicit changed-files selection when status is empty', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',

View File

@ -278,18 +278,17 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
set((state) => {
const panel = getSessionPanelState(state.panelBySession, sessionId)
const shouldDefaultToAllFiles =
!panel.hasUserSelectedView
&& panel.activeView === 'changed'
&& result.state === 'ok'
&& result.changedFiles.length === 0
const nextActiveView =
!panel.hasUserSelectedView && result.state === 'ok'
? result.changedFiles.length > 0 ? 'changed' : 'all'
: panel.activeView
return {
panelBySession: {
...state.panelBySession,
[sessionId]: {
...panel,
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
activeView: nextActiveView,
},
},
statusBySession: {

View File

@ -221,6 +221,96 @@ async function writeFileHistoryBackup(
await fs.writeFile(path.join(dir, backupFileName), content, 'utf-8')
}
type ThreeTurnCheckpointFixture = {
sessionId: string
workDir: string
stepFile: string
createdFile: string
firstUserId: string
secondUserId: string
thirdUserId: string
}
async function createThreeTurnCheckpointFixture(
sessionId: string,
): Promise<ThreeTurnCheckpointFixture> {
const workDir = path.join(tmpDir, `turn-checkpoints-${sessionId}`)
const stepFile = path.join(workDir, 'src', 'step.js')
const createdFile = path.join(workDir, 'notes', 'generated.txt')
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const thirdUserId = crypto.randomUUID()
const backupBase = `${sessionId}-step@v1`
const backupV1 = `${sessionId}-step@v2`
const backupV2 = `${sessionId}-step@v3`
await fs.mkdir(path.dirname(stepFile), { recursive: true })
await fs.mkdir(path.dirname(createdFile), { recursive: true })
await fs.writeFile(stepFile, "export const STEP = 'v3'\n", 'utf-8')
await fs.writeFile(createdFile, 'generated third turn\n', 'utf-8')
await writeFileHistoryBackup(sessionId, backupBase, "export const STEP = 'base'\n")
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'v1'\n")
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v2'\n")
await writeSessionFile('-tmp-api-turn-checkpoints', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(firstUserId, {
'src/step.js': {
backupFileName: backupBase,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v1', firstUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE v1', firstUserId),
makeFileHistorySnapshotEntry(secondUserId, {
'src/step.js': {
backupFileName: backupV1,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v2', secondUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE v2', secondUserId),
makeFileHistorySnapshotEntry(thirdUserId, {
'src/step.js': {
backupFileName: backupV2,
version: 3,
backupTime: '2026-01-01T00:00:00.000Z',
},
'notes/generated.txt': {
backupFileName: null,
version: 3,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v3 and create file', thirdUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE v3', thirdUserId),
])
return {
sessionId,
workDir,
stepFile,
createdFile,
firstUserId,
secondUserId,
thirdUserId,
}
}
// ============================================================================
// SessionService tests
// ============================================================================
@ -1883,6 +1973,172 @@ describe('Sessions API', () => {
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('GET /api/sessions/:id/turn-checkpoints should list completed turn previews with turn-bound diff stats', async () => {
const fixture = await createThreeTurnCheckpointFixture(
'99999999-bbbb-cccc-dddd-eeeeeeeeeeee',
)
const res = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints`)
expect(res.status).toBe(200)
const body = await res.json() as {
checkpoints: Array<{
target: {
targetUserMessageId: string
userMessageIndex: number
userMessageCount: number
}
conversation: { messagesRemoved: number }
code: {
available: boolean
filesChanged: string[]
insertions: number
deletions: number
}
workDir: string
}>
}
expect(body.checkpoints).toHaveLength(3)
expect(body.checkpoints).toEqual([
{
target: {
targetUserMessageId: fixture.firstUserId,
userMessageIndex: 0,
userMessageCount: 3,
},
conversation: { messagesRemoved: 6 },
code: {
available: true,
filesChanged: [fixture.stepFile],
insertions: 1,
deletions: 1,
},
workDir: fixture.workDir,
},
{
target: {
targetUserMessageId: fixture.secondUserId,
userMessageIndex: 1,
userMessageCount: 3,
},
conversation: { messagesRemoved: 4 },
code: {
available: true,
filesChanged: [fixture.stepFile],
insertions: 1,
deletions: 1,
},
workDir: fixture.workDir,
},
{
target: {
targetUserMessageId: fixture.thirdUserId,
userMessageIndex: 2,
userMessageCount: 3,
},
conversation: { messagesRemoved: 2 },
code: {
available: true,
filesChanged: [fixture.stepFile, fixture.createdFile],
insertions: 2,
deletions: 1,
},
workDir: fixture.workDir,
},
])
})
it('GET /api/sessions/:id/turn-checkpoints/diff should return target-bound checkpoint diffs', async () => {
const fixture = await createThreeTurnCheckpointFixture(
'99999999-bbbb-cccc-dddd-ffffffffffff',
)
const secondTurnRes = await fetch(
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.secondUserId}&path=src/step.js`,
)
expect(secondTurnRes.status).toBe(200)
const secondTurnBody = await secondTurnRes.json() as {
state: string
path: string
diff?: string
target: { targetUserMessageId: string }
}
expect(secondTurnBody.target.targetUserMessageId).toBe(fixture.secondUserId)
expect(secondTurnBody.state).toBe('ok')
expect(secondTurnBody.path).toBe('src/step.js')
expect(secondTurnBody.diff).toContain("export const STEP = 'v2'")
expect(secondTurnBody.diff).toContain("export const STEP = 'v1'")
expect(secondTurnBody.diff).not.toContain("export const STEP = 'v3'")
const thirdTurnRes = await fetch(
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`,
)
expect(thirdTurnRes.status).toBe(200)
const thirdTurnBody = await thirdTurnRes.json() as {
state: string
diff?: string
target: { targetUserMessageId: string }
}
expect(thirdTurnBody.target.targetUserMessageId).toBe(fixture.thirdUserId)
expect(thirdTurnBody.state).toBe('ok')
expect(thirdTurnBody.diff).toContain("export const STEP = 'v3'")
expect(thirdTurnBody.diff).toContain("export const STEP = 'v2'")
expect(thirdTurnBody.diff).not.toContain("export const STEP = 'v1'")
const createdFileRes = await fetch(
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=notes/generated.txt`,
)
expect(createdFileRes.status).toBe(200)
const createdFileBody = await createdFileRes.json() as {
state: string
diff?: string
}
expect(createdFileBody.state).toBe('ok')
expect(createdFileBody.diff).toContain('generated third turn')
expect(createdFileBody.diff).toContain('/dev/null')
})
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',
)
const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 0 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'base'\n")
await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
const remainingMessages = await service.getSessionMessages(fixture.sessionId)
expect(remainingMessages).toHaveLength(0)
})
it('POST /api/sessions/:id/rewind should keep the first turn and remove later file changes when rewinding the second turn of a three-turn history', async () => {
const fixture = await createThreeTurnCheckpointFixture(
'aaaaaaaa-5555-6666-7777-888888888888',
)
const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
const remainingMessages = await service.getSessionMessages(fixture.sessionId)
expect(remainingMessages).toHaveLength(2)
expect(remainingMessages[0]?.id).toBe(fixture.firstUserId)
expect(remainingMessages[1]?.type).toBe('assistant')
})
// --------------------------------------------------------------------------
// Conversations API via /api/sessions/:id/chat
// --------------------------------------------------------------------------

View File

@ -7,6 +7,8 @@
* GET /api/sessions
* GET /api/sessions/:id
* GET /api/sessions/:id/messages
* GET /api/sessions/:id/turn-checkpoints checkpoint
* GET /api/sessions/:id/turn-checkpoints/diff checkpoint diff
* POST /api/sessions
* DELETE /api/sessions/:id
* PATCH /api/sessions/:id
@ -21,6 +23,8 @@ import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
executeSessionRewind,
getSessionTurnCheckpointDiff,
listSessionTurnCheckpoints,
previewSessionRewind,
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
@ -95,6 +99,18 @@ export async function handleSessionsApi(
return await rewindSession(req, sessionId)
}
if (subResource === 'turn-checkpoints') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return segments[4] === 'diff'
? await getTurnCheckpointDiff(sessionId, url)
: await getTurnCheckpoints(sessionId)
}
if (subResource === 'slash-commands') {
if (req.method !== 'GET') {
return Response.json(
@ -533,6 +549,41 @@ async function rewindSession(req: Request, sessionId: string): Promise<Response>
return Response.json(result)
}
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
const checkpoints = await listSessionTurnCheckpoints(sessionId)
return Response.json({ checkpoints })
}
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
const path = url.searchParams.get('path')
const userMessageIndex =
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
if (
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
!Number.isInteger(userMessageIndex)
) {
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
}
if (!path) {
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
}
const result = await getSessionTurnCheckpointDiff(
sessionId,
{
targetUserMessageId,
userMessageIndex,
},
path,
)
return Response.json(result)
}
async function patchSession(req: Request, sessionId: string): Promise<Response> {
let body: { title?: string }
try {

View File

@ -1,7 +1,7 @@
import type { UUID } from 'crypto'
import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises'
import { dirname, isAbsolute, join } from 'node:path'
import { diffLines } from 'diff'
import { dirname, isAbsolute, join, relative } from 'node:path'
import { createTwoFilesPatch, diffLines } from 'diff'
import { ApiError } from '../middleware/errorHandler.js'
import {
type FileHistorySnapshot,
@ -49,6 +49,19 @@ export type SessionRewindExecuteResult = SessionRewindPreview & {
}
}
export type SessionTurnCheckpointPreview = SessionRewindPreview & {
workDir: string
}
export type SessionTurnCheckpointDiffResult = {
target: SessionRewindPreview['target']
workDir: string
path: string
state: 'ok' | 'missing' | 'error'
diff?: string
error?: string
}
function normalizeDiffStats(diffStats: {
filesChanged?: string[]
insertions?: number
@ -201,13 +214,13 @@ function findTargetSnapshot(
)
}
function getBackupFileNameFirstVersion(
function getEarliestBackupFileName(
trackingPath: string,
snapshots: FileHistorySnapshot[],
): string | null | undefined {
for (const snapshot of snapshots) {
const backup = snapshot.trackedFileBackups[trackingPath]
if (backup !== undefined && backup.version === 1) {
if (backup !== undefined) {
return backup.backupFileName
}
}
@ -225,7 +238,85 @@ function getBackupFileNameForTarget(
return targetBackup.backupFileName
}
return getBackupFileNameFirstVersion(trackingPath, snapshots)
return getEarliestBackupFileName(trackingPath, snapshots)
}
async function resolveSessionWorkDir(sessionId: string): Promise<string> {
return (
(conversationService.hasSession(sessionId)
? conversationService.getSessionWorkDir(sessionId)
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
)
}
async function resolveCheckpointBaseDir(
sessionId: string,
targetUserMessageId: string,
fallbackWorkDir?: string,
): Promise<string> {
return (
(await sessionService.getSessionMessageCwd(sessionId, targetUserMessageId)) ||
fallbackWorkDir ||
(await resolveSessionWorkDir(sessionId))
)
}
function normalizeComparablePath(filePath: string): string {
return filePath.replace(/\\/g, '/')
}
function toCheckpointResponsePath(
trackingPath: string,
checkpointBaseDir: string,
): string {
if (isAbsolute(trackingPath)) {
return trackingPath
}
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
const relativePath = normalizeComparablePath(relative(checkpointBaseDir, absolutePath))
return relativePath && !relativePath.startsWith('../')
? relativePath
: normalizeComparablePath(trackingPath)
}
function matchesCheckpointPath(
requestedPath: string,
trackingPath: string,
checkpointBaseDir: string,
): boolean {
const normalizedRequestedPath = normalizeComparablePath(requestedPath)
const absolutePath = normalizeComparablePath(
expandTrackingPath(checkpointBaseDir, trackingPath),
)
const responsePath = normalizeComparablePath(
toCheckpointResponsePath(trackingPath, checkpointBaseDir),
)
return normalizedRequestedPath === absolutePath ||
normalizedRequestedPath === normalizeComparablePath(trackingPath) ||
normalizedRequestedPath === responsePath
}
function buildTurnPreview(
target: RewindTarget,
preview: RewindCodePreview,
workDir: string,
): SessionTurnCheckpointPreview {
return {
target: {
targetUserMessageId: target.targetUserMessageId,
userMessageIndex: target.userMessageIndex,
userMessageCount: target.userMessageCount,
},
conversation: {
messagesRemoved: target.messagesRemoved,
},
code: preview,
workDir,
}
}
async function readFileOrNull(filePath: string): Promise<string | null> {
@ -242,6 +333,146 @@ function countInsertedLines(content: string): number {
), 0)
}
function buildCheckpointDiff(
displayPath: string,
oldContent: string,
newContent: string,
oldExists: boolean,
newExists: boolean,
): string {
const oldFileName = oldExists ? `a/${displayPath}` : '/dev/null'
const newFileName = newExists ? `b/${displayPath}` : '/dev/null'
return createTwoFilesPatch(
oldFileName,
newFileName,
oldContent,
newContent,
'',
'',
{ context: 3 },
)
}
async function readBackupContent(
sessionId: string,
backupFileName: string | null | undefined,
): Promise<string | null | undefined> {
if (backupFileName === undefined) return undefined
if (backupFileName === null) return null
return await readFileOrNull(resolveBackupPath(sessionId, backupFileName))
}
function countTurnDiffStats(
beforeContent: string | null,
afterContent: string | null,
): { insertions: number; deletions: number } {
let insertions = 0
let deletions = 0
for (const change of diffLines(beforeContent ?? '', afterContent ?? '')) {
if (change.added) insertions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { insertions, deletions }
}
function getTurnMessageRange(
activeMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
targetUserMessageId: string,
): { start: number; end: number } | null {
const start = activeMessages.findIndex((message) => message.id === targetUserMessageId)
if (start < 0) return null
const nextUserIndex = activeMessages.findIndex(
(message, index) => index > start && message.type === 'user',
)
return { start, end: nextUserIndex >= 0 ? nextUserIndex : activeMessages.length }
}
function hasCompletedTurn(
activeMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
targetUserMessageId: string,
): boolean {
const range = getTurnMessageRange(activeMessages, targetUserMessageId)
if (!range) return false
return activeMessages.slice(range.start + 1, range.end).some((message) =>
message.type === 'assistant' ||
message.type === 'tool_use' ||
message.type === 'tool_result' ||
message.type === 'error',
)
}
function getNextUserMessageId(
userMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
userMessageIndex: number,
): string | null {
return userMessages[userMessageIndex + 1]?.id ?? null
}
async function getTurnBoundaryContents(
sessionId: string,
checkpointBaseDir: string,
trackingPath: string,
targetSnapshot: FileHistorySnapshot,
nextSnapshot: FileHistorySnapshot | null,
): Promise<{ beforeContent: string | null; afterContent: string | null }> {
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
const beforeContent = await readBackupContent(
sessionId,
targetSnapshot.trackedFileBackups[trackingPath]?.backupFileName,
)
if (!nextSnapshot) {
return {
beforeContent: beforeContent ?? null,
afterContent: await readFileOrNull(absolutePath),
}
}
const nextContent = await readBackupContent(
sessionId,
nextSnapshot.trackedFileBackups[trackingPath]?.backupFileName,
)
return {
beforeContent: beforeContent ?? null,
afterContent: nextContent === undefined ? beforeContent ?? null : nextContent,
}
}
async function buildTurnCodePreview(
sessionId: string,
checkpointBaseDir: string,
targetSnapshot: FileHistorySnapshot,
nextSnapshot: FileHistorySnapshot | null,
): Promise<RewindCodePreview> {
const trackedPaths = new Set([
...Object.keys(targetSnapshot.trackedFileBackups),
...Object.keys(nextSnapshot?.trackedFileBackups ?? {}),
])
const filesChanged: string[] = []
let insertions = 0
let deletions = 0
for (const trackingPath of trackedPaths) {
const { beforeContent, afterContent } = await getTurnBoundaryContents(
sessionId,
checkpointBaseDir,
trackingPath,
targetSnapshot,
nextSnapshot,
)
if (beforeContent === afterContent) continue
filesChanged.push(expandTrackingPath(checkpointBaseDir, trackingPath))
const stats = countTurnDiffStats(beforeContent, afterContent)
insertions += stats.insertions
deletions += stats.deletions
}
return normalizeDiffStats({ filesChanged, insertions, deletions })
}
async function hasFileChanged(
filePath: string,
backupFilePath: string,
@ -378,15 +609,12 @@ export async function previewSessionRewind(
selector: RewindTargetSelector,
): Promise<SessionRewindPreview> {
const target = await resolveRewindTarget(sessionId, selector)
const workDir =
(conversationService.hasSession(sessionId)
? conversationService.getSessionWorkDir(sessionId)
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
const checkpointBaseDir =
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
workDir
const workDir = await resolveSessionWorkDir(sessionId)
const checkpointBaseDir = await resolveCheckpointBaseDir(
sessionId,
target.targetUserMessageId,
workDir,
)
const { preview } = await buildCodePreview(
sessionId,
checkpointBaseDir,
@ -406,20 +634,167 @@ export async function previewSessionRewind(
}
}
export async function listSessionTurnCheckpoints(
sessionId: string,
): Promise<SessionTurnCheckpointPreview[]> {
const activeMessages = await sessionService.getSessionMessages(sessionId)
const userMessages = activeMessages.filter((message) => message.type === 'user')
if (userMessages.length === 0) {
return []
}
const workDir = await resolveSessionWorkDir(sessionId)
const snapshots = await loadFileHistorySnapshots(sessionId)
const checkpoints: SessionTurnCheckpointPreview[] = []
for (const [userMessageIndex, userMessage] of userMessages.entries()) {
const activeMessageIndex = activeMessages.findIndex(
(message) => message.id === userMessage.id,
)
if (activeMessageIndex < 0) continue
if (!hasCompletedTurn(activeMessages, userMessage.id)) continue
const target: RewindTarget = {
targetUserMessageId: userMessage.id,
userMessageIndex,
userMessageCount: userMessages.length,
messagesRemoved: activeMessages.length - activeMessageIndex,
}
const checkpointBaseDir = await resolveCheckpointBaseDir(
sessionId,
target.targetUserMessageId,
workDir,
)
const targetSnapshot = snapshots ? findTargetSnapshot(snapshots, target.targetUserMessageId) : null
const nextUserMessageId = getNextUserMessageId(userMessages, userMessageIndex)
const nextSnapshot = nextUserMessageId && snapshots
? findTargetSnapshot(snapshots, nextUserMessageId)
: null
const preview = targetSnapshot
? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot)
: {
available: false,
reason: 'No file checkpoint is available for the selected message.',
filesChanged: [],
insertions: 0,
deletions: 0,
}
if (!preview.available || preview.filesChanged.length === 0) continue
checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir))
}
return checkpoints
}
export async function getSessionTurnCheckpointDiff(
sessionId: string,
selector: RewindTargetSelector,
requestedPath: string,
): Promise<SessionTurnCheckpointDiffResult> {
const target = await resolveRewindTarget(sessionId, selector)
const workDir = await resolveSessionWorkDir(sessionId)
const checkpointBaseDir = await resolveCheckpointBaseDir(
sessionId,
target.targetUserMessageId,
workDir,
)
const snapshots = await loadFileHistorySnapshots(sessionId)
const missingResult = {
target: buildTurnPreview(
target,
{
available: false,
filesChanged: [],
insertions: 0,
deletions: 0,
},
checkpointBaseDir,
).target,
workDir: checkpointBaseDir,
path: normalizeComparablePath(requestedPath),
state: 'missing' as const,
}
if (!snapshots) {
return missingResult
}
const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId)
if (!targetSnapshot) {
return missingResult
}
const userMessages = (await sessionService.getSessionMessages(sessionId))
.filter((message) => message.type === 'user')
const nextUserMessageId = getNextUserMessageId(userMessages, target.userMessageIndex)
const nextSnapshot = nextUserMessageId
? findTargetSnapshot(snapshots, nextUserMessageId)
: null
for (const trackingPath of new Set([
...Object.keys(targetSnapshot.trackedFileBackups),
...Object.keys(nextSnapshot?.trackedFileBackups ?? {}),
])) {
if (!matchesCheckpointPath(requestedPath, trackingPath, checkpointBaseDir)) {
continue
}
const displayPath = toCheckpointResponsePath(trackingPath, checkpointBaseDir)
try {
const { beforeContent, afterContent } = await getTurnBoundaryContents(
sessionId,
checkpointBaseDir,
trackingPath,
targetSnapshot,
nextSnapshot,
)
if (beforeContent === afterContent) {
return {
...missingResult,
path: displayPath,
}
}
return {
target: missingResult.target,
workDir: checkpointBaseDir,
path: displayPath,
state: 'ok',
diff: buildCheckpointDiff(
displayPath,
beforeContent ?? '',
afterContent ?? '',
beforeContent !== null,
afterContent !== null,
),
}
} catch (error) {
return {
target: missingResult.target,
workDir: checkpointBaseDir,
path: displayPath,
state: 'error',
error: error instanceof Error ? error.message : String(error),
}
}
}
return missingResult
}
export async function executeSessionRewind(
sessionId: string,
selector: RewindTargetSelector,
): Promise<SessionRewindExecuteResult> {
const target = await resolveRewindTarget(sessionId, selector)
const workDir =
(conversationService.hasSession(sessionId)
? conversationService.getSessionWorkDir(sessionId)
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
const checkpointBaseDir =
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
workDir
const workDir = await resolveSessionWorkDir(sessionId)
const checkpointBaseDir = await resolveCheckpointBaseDir(
sessionId,
target.targetUserMessageId,
workDir,
)
const { snapshots, preview } = await buildCodePreview(
sessionId,
checkpointBaseDir,