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) {