From 3a6f45d17384820a26aa6855d834f3670dee37ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 29 Apr 2026 19:29:54 +0800 Subject: [PATCH] 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 --- .../components/chat/CurrentTurnChangeCard.tsx | 199 ++++++++++++++++ .../src/components/chat/MessageList.test.tsx | 221 ++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 196 ++++++++++++++++ .../workspace/WorkspacePanel.test.tsx | 9 +- .../components/workspace/WorkspacePanel.tsx | 11 +- desktop/src/i18n/locales/en.ts | 10 + desktop/src/i18n/locales/zh.ts | 10 + desktop/src/pages/ActiveSession.test.tsx | 3 +- desktop/src/pages/ActiveSession.tsx | 6 +- src/server/__tests__/sessions.test.ts | 77 ++++++ src/server/services/sessionRewindService.ts | 44 +++- 11 files changed, 766 insertions(+), 20 deletions(-) create mode 100644 desktop/src/components/chat/CurrentTurnChangeCard.tsx diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx new file mode 100644 index 00000000..60326a83 --- /dev/null +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -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(null) + const [diffByPath, setDiffByPath] = useState>({}) + + 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 ( +
+
+
+
+ + {t('chat.turnChangesTitle', { count: files.length })} + + + +{preview.code.insertions} + + + -{preview.code.deletions} + +
+
+ {t('chat.turnChangesSubtitle')} +
+
+ + +
+ +
+ {files.map((filePath) => { + const isExpanded = expandedPath === filePath + const diffState = diffByPath[filePath] + return ( +
+ + + {isExpanded && ( +
+ {diffState?.loading ? ( +
+ {t('chat.turnChangesDiffLoading')} +
+ ) : diffState?.error ? ( +
+ {diffState.error} +
+ ) : diffState?.diff ? ( + + ) : ( +
+ {t('chat.turnChangesDiffUnavailable')} +
+ )} +
+ )} +
+ ) + })} +
+ + {error && ( +
+ {error} +
+ )} +
+ ) +} + +function DiffPreview({ diff }: { diff: string }) { + const lines = diff.split('\n').slice(0, 220) + return ( +
+      {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 (
+          
+ {line || ' '} +
+ ) + })} +
+ ) +} + +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 +} diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index ff2ae162..4597f680 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + + 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() + + 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() + + 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: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 2f7d5bbc..3ec44029 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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 } +type RewindTurnTarget = { + messageId: string + userMessageIndex: number + content: string + attachments?: Extract['attachments'] +} + +type CurrentTurnPreview = { + target: RewindTurnTarget + preview: SessionRewindResponse + workDir: string | null +} + function appendChildToolCall( childToolCallsByParent: Map, 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(null) const [isLoadingPreview, setIsLoadingPreview] = useState(false) const [isExecutingRewind, setIsExecutingRewind] = useState(false) + const [currentTurnPreview, setCurrentTurnPreview] = useState(null) + const [currentTurnError, setCurrentTurnError] = useState(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 = { )} + {!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && ( + { + void handleUndoCurrentTurn() + }} + /> + )} + + {!currentTurnPreview && currentTurnError && ( +
+ {currentTurnError} +
+ )} +
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 500ecb0f..4d969ba4 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -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) }) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index a7007105..50431de4 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -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) {