feat: make current turn changes reviewable and undoable

Workspace inspection was visible only as a side panel, while file restore still lived behind the older per-message rewind affordance. This adds a chat-flow change card for the completed turn, keeps undo routed through checkpoint rewind, and tightens the workspace split layout so narrow and fullscreen windows keep both chat and file preview usable.

Constraint: Current session file changes must work for non-Git directories when transcript tool edits are available
Constraint: Workspace preview must remain right-docked without horizontal overflow across narrow and wide desktop viewports
Rejected: Keep only hover rewind | users could not clearly see what the current turn changed before undoing it
Rejected: Use Git as the only changed-file source | many desktop sessions run in temporary or non-Git directories
Confidence: high
Scope-risk: moderate
Directive: Keep checkpoint rewind as the source of truth for undo semantics; workspace diffs are a preview surface, not the restore authority
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts src/pages/ActiveSession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: bun /tmp/cc-haha-layout-e2e/run-layout-e2e.mjs
Not-tested: Native Tauri packaged app window chrome behavior
This commit is contained in:
程序员阿江(Relakkes) 2026-04-29 19:29:54 +08:00
parent b811ffb8f1
commit 3a6f45d173
11 changed files with 766 additions and 20 deletions

View File

@ -0,0 +1,199 @@
import { useCallback, useMemo, useState } from 'react'
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
import { useTranslation } from '../../i18n'
type DiffPreviewState = {
loading: boolean
diff?: string
error?: string
}
type CurrentTurnChangeCardProps = {
sessionId: string
preview: SessionRewindResponse
workDir: string | null
error: string | null
isUndoing: boolean
onUndo: () => void
}
export function CurrentTurnChangeCard({
sessionId,
preview,
workDir,
error,
isUndoing,
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 toggleDiff = useCallback((filePath: string) => {
const nextExpandedPath = expandedPath === filePath ? null : filePath
setExpandedPath(nextExpandedPath)
if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) {
return
}
setDiffByPath((current) => ({
...current,
[filePath]: { loading: true },
}))
void sessionsApi
.getWorkspaceDiff(sessionId, filePath)
.then((result) => {
setDiffByPath((current) => ({
...current,
[filePath]: {
loading: false,
diff: result.state === 'ok' ? result.diff || '' : undefined,
error: result.state === 'ok'
? undefined
: result.error || t('chat.turnChangesDiffUnavailable'),
},
}))
})
.catch((diffError) => {
setDiffByPath((current) => ({
...current,
[filePath]: {
loading: false,
error: diffError instanceof Error
? diffError.message
: String(diffError),
},
}))
})
}, [diffByPath, expandedPath, sessionId, t])
return (
<section
className="mx-auto mb-5 max-w-[760px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
aria-label={t('chat.turnChangesCardLabel')}
>
<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">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
+{preview.code.insertions}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
-{preview.code.deletions}
</span>
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesSubtitle')}
</div>
</div>
<button
type="button"
onClick={onUndo}
disabled={isUndoing}
aria-label={t('chat.turnChangesUndoAria')}
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')}
</button>
</div>
<div className="divide-y divide-[var(--color-border)]">
{files.map((filePath) => {
const isExpanded = expandedPath === filePath
const diffState = diffByPath[filePath]
return (
<div key={filePath}>
<button
type="button"
onClick={() => toggleDiff(filePath)}
aria-label={t(
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: filePath },
)}
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"
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{filePath}
</span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3">
{diffState?.loading ? (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesDiffLoading')}
</div>
) : diffState?.error ? (
<div className="text-xs text-[var(--color-error)]">
{diffState.error}
</div>
) : diffState?.diff ? (
<DiffPreview diff={diffState.diff} />
) : (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesDiffUnavailable')}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{error && (
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{error}
</div>
)}
</section>
)
}
function DiffPreview({ diff }: { diff: string }) {
const lines = diff.split('\n').slice(0, 220)
return (
<pre className="max-h-[360px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)] p-3 font-mono text-[12px] leading-5 text-[var(--color-code-fg)]">
{lines.map((line, index) => {
const colorClass = line.startsWith('+') && !line.startsWith('+++')
? 'text-[var(--color-success)]'
: line.startsWith('-') && !line.startsWith('---')
? 'text-[var(--color-error)]'
: line.startsWith('@@')
? 'text-[var(--color-brand)]'
: 'text-[var(--color-code-fg)]'
return (
<div key={`${index}-${line}`} className={`${colorClass} whitespace-pre`}>
{line || ' '}
</div>
)
})}
</pre>
)
}
function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
const normalizedPath = filePath.replace(/\\/g, '/')
if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath
const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '')
if (normalizedPath === normalizedWorkDir) return ''
if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) {
return normalizedPath.slice(normalizedWorkDir.length + 1)
}
return normalizedPath
}

View File

@ -682,6 +682,227 @@ describe('MessageList nested tool calls', () => {
})
})
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,
},
})
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '第一段',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'ok',
timestamp: 2,
},
{
id: 'user-2',
type: 'user_text',
content: '第二段',
timestamp: 3,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'done',
timestamp: 4,
},
],
}),
},
})
render(<MessageList />)
expect(await screen.findByText('2 files changed')).toBeTruthy()
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: '第二段',
dryRun: true,
})
})
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,
},
})
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
state: 'ok',
path: 'src/App.tsx',
diff: 'diff --session a/src/App.tsx b/src/App.tsx\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,
},
],
}),
},
})
render(<MessageList />)
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
expect(await screen.findByText('+new')).toBeTruthy()
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
})
it('undoes the current turn from the change card using checkpoint rewind', async () => {
vi.spyOn(sessionsApi, 'rewind')
.mockResolvedValueOnce({
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: ['src/App.tsx'],
insertions: 1,
deletions: 0,
},
})
.mockResolvedValueOnce({
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
conversation: {
messagesRemoved: 2,
removedMessageIds: ['user-1', 'assistant-1'],
},
code: {
available: true,
filesChanged: ['src/App.tsx'],
insertions: 1,
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()
useChatStore.setState({
reloadHistory,
queueComposerPrefill,
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
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' }))
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('shows raw startup details under translated CLI startup errors', () => {
useChatStore.setState({
sessions: {

View File

@ -17,6 +17,7 @@ import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion'
import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { Modal } from '../shared/Modal'
import { Button } from '../shared/Button'
@ -34,6 +35,19 @@ type RenderModel = {
childToolCallsByParent: Map<string, ToolCall[]>
}
type RewindTurnTarget = {
messageId: string
userMessageIndex: number
content: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type CurrentTurnPreview = {
target: RewindTurnTarget
preview: SessionRewindResponse
workDir: string | null
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
@ -104,6 +118,41 @@ 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
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,
attachments: message.attachments,
messageOffset,
}
}
if (!latestTarget) return null
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',
)
if (!hasResponseAfterTarget) return null
const { messageOffset: _messageOffset, ...target } = latestTarget
return target
}
type MessageListProps = {
sessionId?: string | null
compact?: boolean
@ -151,6 +200,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const [rewindError, setRewindError] = useState<string | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
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 updateAutoScrollState = useCallback(() => {
const container = scrollContainerRef.current
@ -216,6 +269,69 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
useEffect(() => {
if (
!resolvedSessionId ||
!latestTurnTarget ||
chatState !== 'idle' ||
isMemberSession
) {
setCurrentTurnPreview(null)
setCurrentTurnError(null)
setIsLoadingCurrentTurnPreview(false)
return
}
let cancelled = false
setIsLoadingCurrentTurnPreview(true)
setCurrentTurnPreview(null)
setCurrentTurnError(null)
Promise.all([
sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: latestTurnTarget.messageId,
userMessageIndex: latestTurnTarget.userMessageIndex,
expectedContent: latestTurnTarget.content,
dryRun: true,
}),
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
])
.then(([preview, workspaceStatus]) => {
if (cancelled) return
if (!preview.code.available || preview.code.filesChanged.length === 0) {
setCurrentTurnPreview(null)
return
}
setCurrentTurnPreview({
target: latestTurnTarget,
preview,
workDir: workspaceStatus?.workDir ?? null,
})
})
.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)
})
.finally(() => {
if (!cancelled) {
setIsLoadingCurrentTurnPreview(false)
}
})
return () => {
cancelled = true
}
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
const closeRewindModal = useCallback(() => {
if (isExecutingRewind) return
@ -286,6 +402,67 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
t,
])
const handleUndoCurrentTurn = useCallback(async () => {
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
const target = currentTurnPreview.target
setIsUndoingCurrentTurn(true)
setCurrentTurnError(null)
try {
if (chatState !== 'idle') {
stopGeneration(resolvedSessionId)
}
const result = await sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: target.messageId,
userMessageIndex: target.userMessageIndex,
expectedContent: target.content,
})
await reloadHistory(resolvedSessionId)
queueComposerPrefill(resolvedSessionId, {
text: target.content,
attachments: target.attachments,
})
addToast({
type: 'success',
message: result.code.available
? t('chat.rewindSuccessWithCode', {
count: result.conversation.messagesRemoved,
})
: t('chat.rewindSuccessConversationOnly', {
count: result.conversation.messagesRemoved,
}),
})
setCurrentTurnPreview(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)
} finally {
setIsUndoingCurrentTurn(false)
}
}, [
addToast,
chatState,
currentTurnPreview,
isUndoingCurrentTurn,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
stopGeneration,
t,
])
let visibleUserMessageIndex = -1
return (
@ -360,6 +537,25 @@ 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={() => {
void handleUndoCurrentTurn()
}}
/>
)}
{!currentTurnPreview && currentTurnError && (
<div className="mx-auto mb-5 max-w-[760px] 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}
</div>
)}
<div ref={bottomRef} />
</div>

View File

@ -164,7 +164,10 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-changed')
expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)')
const compactPanel = view.getByTestId('workspace-panel')
expect(compactPanel.style.width).toBe('520px')
expect(compactPanel.style.maxWidth).toBe('36%')
expect(compactPanel.style.minWidth).toBe('min(340px, 40%)')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed')
@ -209,6 +212,10 @@ describe('WorkspacePanel', () => {
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
})
const expandedPanel = view.getByTestId('workspace-panel')
expect(expandedPanel.style.width).toBe('860px')
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})

View File

@ -738,6 +738,11 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
if (!isOpen) return null
const hasPreviewTabs = previewTabs.length > 0
const panelWidth = hasPreviewTabs ? width : Math.min(width, 520)
const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%'
const panelMinWidth = hasPreviewTabs ? 'min(420px, 54%)' : 'min(340px, 40%)'
const handleRefresh = () => {
void loadStatus(sessionId)
if (activeView === 'all') {
@ -975,9 +980,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
<aside
data-testid="workspace-panel"
className="flex h-full shrink-0 border-l border-[#e7e7e7] bg-white"
style={{ width, maxWidth: 'calc(100% - 348px)' }}
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
>
{previewTabs.length > 0 && (
{hasPreviewTabs && (
<div className="flex min-w-0 flex-1 flex-col border-r border-[#e7e7e7] bg-white">
{renderPreviewTabs()}
{renderPreviewContent()}
@ -985,7 +990,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
)}
<div
className={`${previewTabs.length > 0 ? 'basis-[38%] min-w-[210px] max-w-[360px]' : 'w-full'} flex h-full shrink-0 flex-col bg-white`}
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-white`}
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[#ececec] px-3">
<div className="relative min-w-0">

View File

@ -634,6 +634,16 @@ export const en = {
'chat.rewindFilesMore': '+{count} more',
'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.turnChangesUndoing': 'Undoing...',
'chat.turnChangesUndoAria': 'Undo current turn changes',
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
'chat.turnChangesDiffLoading': 'Loading diff...',
'chat.turnChangesDiffUnavailable': 'Diff unavailable.',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': 'Thinking',

View File

@ -636,6 +636,16 @@ export const zh: Record<TranslationKey, string> = {
'chat.rewindFilesMore': '另有 {count} 个',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesCardLabel': '当前轮次已更改文件',
'chat.turnChangesTitle': '{count} 个文件已更改',
'chat.turnChangesSubtitle': '当前轮次检查点',
'chat.turnChangesUndo': '撤销',
'chat.turnChangesUndoing': '正在撤销...',
'chat.turnChangesUndoAria': '撤销当前轮次变更',
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
'chat.turnChangesDiffLoading': '正在加载 diff...',
'chat.turnChangesDiffUnavailable': 'Diff 不可用。',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': '思考中',

View File

@ -249,7 +249,8 @@ describe('ActiveSession task polling', () => {
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
expect(chatColumn).toHaveClass('shrink-0')
expect(chatColumn).toHaveClass('flex-1')
expect(chatColumn).not.toHaveClass('shrink-0')
expect(contentRow.children[0]).toBe(chatColumn)
expect(contentRow.children[1]).toBe(resizeHandle)
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))

View File

@ -15,8 +15,8 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
const CHAT_COLUMN_COMPACT_CLASS =
'w-[clamp(340px,23vw,420px)] min-w-[340px] max-w-[420px] shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
function WorkspaceResizeHandle() {
const t = useTranslation()
@ -165,7 +165,7 @@ export function ActiveSession() {
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
<div
data-testid="active-session-chat-column"
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_COMPACT_CLASS : 'min-w-[360px] flex-1'}`}
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : 'min-w-[360px] flex-1'}`}
>
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">

View File

@ -1667,6 +1667,83 @@ describe('Sessions API', () => {
expect(remainingMessages[0]?.id).toBe(firstUserId)
})
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'created-on-second-turn')
const firstFile = path.join(workDir, 'src', 'step.js')
const createdFile = path.join(workDir, 'notes', 'generated.txt')
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const backupV1 = 'second-created-step@v1'
const backupV2 = 'second-created-step@v2'
await fs.mkdir(path.dirname(firstFile), { recursive: true })
await fs.mkdir(path.dirname(createdFile), { recursive: true })
await fs.writeFile(firstFile, "export const STEP = 'v2'\n", 'utf-8')
await fs.writeFile(createdFile, 'generated\n', 'utf-8')
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n")
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n")
await writeSessionFile('-tmp-api-second-turn-created', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(firstUserId, {
'src/step.js': {
backupFileName: backupV1,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v1', firstUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE', firstUserId),
makeFileHistorySnapshotEntry(secondUserId, {
'src/step.js': {
backupFileName: backupV2,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
'notes/generated.txt': {
backupFileName: null,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v2 and create file', secondUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE', secondUserId),
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[]; insertions: number }
}
expect(preview.code.filesChanged.sort()).toEqual([
createdFile,
firstFile,
].sort())
expect(preview.code.insertions).toBe(2)
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(firstFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
})
// --------------------------------------------------------------------------
// Conversations API via /api/sessions/:id/chat
// --------------------------------------------------------------------------

View File

@ -215,6 +215,19 @@ function getBackupFileNameFirstVersion(
return undefined
}
function getBackupFileNameForTarget(
trackingPath: string,
snapshots: FileHistorySnapshot[],
targetSnapshot: FileHistorySnapshot,
): string | null | undefined {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
if (targetBackup && 'backupFileName' in targetBackup) {
return targetBackup.backupFileName
}
return getBackupFileNameFirstVersion(trackingPath, snapshots)
}
async function readFileOrNull(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8')
@ -223,6 +236,12 @@ async function readFileOrNull(filePath: string): Promise<string | null> {
}
}
function countInsertedLines(content: string): number {
return diffLines('', content).reduce((total, change) => (
change.added ? total + (change.count || 0) : total
), 0)
}
async function hasFileChanged(
filePath: string,
backupFilePath: string,
@ -305,21 +324,21 @@ async function buildCodePreview(
let deletions = 0
for (const trackingPath of trackedPaths) {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
const backupFileName =
targetBackup?.backupFileName ??
getBackupFileNameFirstVersion(trackingPath, snapshots)
const backupFileName = getBackupFileNameForTarget(
trackingPath,
snapshots,
targetSnapshot,
)
if (backupFileName === undefined) continue
const absolutePath = expandTrackingPath(workDir, trackingPath)
if (backupFileName === null) {
try {
await stat(absolutePath)
const currentContent = await readFileOrNull(absolutePath)
if (currentContent !== null) {
filesChanged.push(absolutePath)
} catch {
// File is already absent.
insertions += countInsertedLines(currentContent)
}
continue
}
@ -412,10 +431,11 @@ export async function executeSessionRewind(
}
for (const trackingPath of collectTrackedPaths(snapshots)) {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
const backupFileName =
targetBackup?.backupFileName ??
getBackupFileNameFirstVersion(trackingPath, snapshots)
const backupFileName = getBackupFileNameForTarget(
trackingPath,
snapshots,
targetSnapshot,
)
if (backupFileName === undefined) continue