diff --git a/desktop/scripts/e2e-rewind-agent-browser.sh b/desktop/scripts/e2e-rewind-agent-browser.sh new file mode 100755 index 00000000..d1bb2172 --- /dev/null +++ b/desktop/scripts/e2e-rewind-agent-browser.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-rewind-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-e2e-${RUN_ID}-XXXX")" +PROJECT_DIR="${ARTIFACT_DIR}/project" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +mkdir -p "${PROJECT_DIR}/src" +cat > "${PROJECT_DIR}/src/app.js" <<'EOF' +export const ORIGINAL_VALUE = 'before-rewind' + +export function readValue() { + return ORIGINAL_VALUE +} +EOF +cat > "${PROJECT_DIR}/README.md" <<'EOF' +# Rewind E2E Fixture + +This project is created automatically by the agent-browser rewind E2E script. +EOF +cat > "${PROJECT_DIR}/package.json" <<'EOF' +{ + "name": "rewind-e2e-fixture", + "private": true, + "type": "module" +} +EOF + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1 +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \ + -H 'Content-Type: application/json' \ + -d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')" + +UNIQUE_TITLE="E2E Rewind ${RUN_ID}" +curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \ + -H 'Content-Type: application/json' \ + -d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null + +PROMPT="Edit src/app.js and replace before-rewind with after-rewind. Do not modify any other file. Reply with DONE when finished." +TARGET_FILE="${PROJECT_DIR}/src/app.js" + +AB="agent-browser --session ${SESSION_NAME}" + +${AB} open "${WEB_URL}" +${AB} wait 2000 +${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null + +${AB} fill '#sidebar-search' "${UNIQUE_TITLE}" +${AB} find text "${UNIQUE_TITLE}" click +${AB} wait 1000 +${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null + +${AB} fill 'textarea' "${PROMPT}" +${AB} find role button click --name "Run" + +for _ in $(seq 1 180); do + ${AB} find role button click --name "Allow" >/dev/null 2>&1 || true + ${AB} find role button click --name "Allow for session" >/dev/null 2>&1 || true + if grep -q "after-rewind" "${TARGET_FILE}"; then + break + fi + sleep 2 +done + +if ! grep -q "after-rewind" "${TARGET_FILE}"; then + echo "Timed out waiting for edited file contents" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-edit-timeout.png" >/dev/null || true + exit 1 +fi + +${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null + +${AB} eval "const button = [...document.querySelectorAll('button')].find((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (!button) throw new Error('Rewind button not found'); button.click();" +${AB} wait 1500 +${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null +${AB} find role button click --name "Rewind here" + +for _ in $(seq 1 120); do + if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then + break + fi + sleep 1 +done + +if ! grep -q "before-rewind" "${TARGET_FILE}" || grep -q "after-rewind" "${TARGET_FILE}"; then + echo "Timed out waiting for rewind to restore original file contents" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-rewind-timeout.png" >/dev/null || true + exit 1 +fi + +${AB} wait 1000 +PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')" +if [[ "${PREFILL_VALUE}" != "${PROMPT}" ]]; then + echo "Composer prefill mismatch after rewind" >&2 + echo "Expected: ${PROMPT}" >&2 + echo "Actual: ${PREFILL_VALUE}" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true + exit 1 +fi + +MESSAGE_COUNT="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.messages.length);')" +if [[ "${MESSAGE_COUNT}" != "0" ]]; then + echo "Expected rewound session to have 0 transcript messages, got ${MESSAGE_COUNT}" >&2 + exit 1 +fi + +${AB} screenshot "${ARTIFACT_DIR}/05-after-rewind.png" >/dev/null + +echo "Rewind E2E passed" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index c1a21c3d..ef39c789 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -4,6 +4,23 @@ import type { SessionListItem, MessageEntry } from '../types/session' type SessionsResponse = { sessions: SessionListItem[]; total: number } type MessagesResponse = { messages: MessageEntry[] } type CreateSessionResponse = { sessionId: string } +export type SessionRewindResponse = { + target: { + userMessageIndex: number + userMessageCount: number + } + conversation: { + messagesRemoved: number + removedMessageIds?: string[] + } + code: { + available: boolean + reason?: string + filesChanged: string[] + insertions: number + deletions: number + } +} export type RecentProject = { projectPath: string @@ -54,4 +71,10 @@ export const sessionsApi = { getSlashCommands(sessionId: string) { return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, + + rewind(sessionId: string, body: { userMessageIndex: number; dryRun?: boolean }) { + return api.post(`/api/sessions/${sessionId}/rewind`, body, { + timeout: 60_000, + }) + }, } diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index b9cff77a..e6659d90 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -57,6 +57,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] + const composerPrefill = sessionState?.composerPrefill ?? null const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) @@ -72,6 +73,37 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { textareaRef.current?.focus() }, [isActive]) + useEffect(() => { + if (!composerPrefill) return + + setInput(composerPrefill.text) + setAttachments( + (composerPrefill.attachments ?? []) + .filter((attachment) => attachment.type === 'image' || attachment.data) + .map((attachment, index) => ({ + id: `rewind-prefill-${composerPrefill.nonce}-${index}`, + name: attachment.name, + type: attachment.type, + mimeType: attachment.mimeType, + previewUrl: attachment.type === 'image' ? attachment.data : undefined, + data: attachment.data, + })), + ) + setPlusMenuOpen(false) + setSlashMenuOpen(false) + setFileSearchOpen(false) + setSlashFilter('') + setAtFilter('') + setAtCursorPos(-1) + + requestAnimationFrame(() => { + const el = textareaRef.current + el?.focus() + const cursor = composerPrefill.text.length + el?.setSelectionRange(cursor, cursor) + }) + }, [composerPrefill]) + useEffect(() => { if (!activeTabId) { setGitInfo(null) diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 1943ddb5..00c941d8 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -3,25 +3,46 @@ import { CopyButton } from '../shared/CopyButton' type Props = { copyText?: string copyLabel: string + onRewind?: () => void + rewindLabel?: string } export function MessageActionBar({ copyText, copyLabel, + onRewind, + rewindLabel = 'Rewind to here', }: Props) { const hasCopy = Boolean(copyText?.trim()) + const hasRewind = Boolean(onRewind) - if (!hasCopy) return null + if (!hasCopy && !hasRewind) return null return (
- +
+ {hasRewind && ( + + )} + {hasCopy && ( + + )} +
) } diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 74bcfe25..09e8866d 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, buildRenderModel } from './MessageList' +import { sessionsApi } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import type { UIMessage } from '../../types/chat' @@ -26,12 +27,14 @@ function makeSessionState(overrides: Partial = {}): PerSessionS slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, + composerPrefill: null, ...overrides, } } describe('MessageList nested tool calls', () => { beforeEach(() => { + vi.restoreAllMocks() useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } }) }) @@ -335,6 +338,52 @@ describe('MessageList nested tool calls', () => { ) }) + it('opens a rewind preview modal for user messages', async () => { + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/example.ts'], + insertions: 6, + deletions: 2, + }, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '回到这一步重做', + timestamp: 1, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Rewind to here' })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Rewind Conversation')).toBeTruthy() + expect(within(dialog).getByText('回到这一步重做')).toBeTruthy() + expect(within(dialog).getByText('src/example.ts')).toBeTruthy() + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + userMessageIndex: 0, + dryRun: true, + }) + }) + 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 8c1876e2..84fa723d 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,6 +1,10 @@ -import { useRef, useEffect, useMemo, memo } from 'react' +import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react' +import { ApiError } from '../../api/client' +import { sessionsApi, type SessionRewindResponse } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' +import { useTeamStore } from '../../stores/teamStore' +import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' @@ -14,6 +18,8 @@ import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import type { AgentTaskNotification, UIMessage } from '../../types/chat' +import { Modal } from '../shared/Modal' +import { Button } from '../shared/Button' type ToolCall = Extract type ToolResult = Extract @@ -111,22 +117,147 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { export function MessageList() { const activeTabId = useTabStore((s) => s.activeTabId) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const stopGeneration = useChatStore((s) => s.stopGeneration) + const reloadHistory = useChatStore((s) => s.reloadHistory) + const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) + const isMemberSession = useTeamStore((s) => activeTabId ? Boolean(s.getMemberBySessionId(activeTabId)) : false) + const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? [] const chatState = sessionState?.chatState ?? 'idle' const streamingText = sessionState?.streamingText ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} const bottomRef = useRef(null) + const t = useTranslation() + const [rewindTarget, setRewindTarget] = useState<{ + userMessageIndex: number + content: string + attachments?: Extract['attachments'] + } | null>(null) + const [rewindPreview, setRewindPreview] = useState(null) + const [rewindError, setRewindError] = useState(null) + const [isLoadingPreview, setIsLoadingPreview] = useState(false) + const [isExecutingRewind, setIsExecutingRewind] = useState(false) useEffect(() => { bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages.length, streamingText]) + useEffect(() => { + if (!activeTabId || !rewindTarget) return + + let cancelled = false + setIsLoadingPreview(true) + setRewindPreview(null) + setRewindError(null) + + void sessionsApi + .rewind(activeTabId, { + userMessageIndex: rewindTarget.userMessageIndex, + dryRun: true, + }) + .then((preview) => { + if (!cancelled) { + setRewindPreview(preview) + } + }) + .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) + setRewindError(message) + }) + .finally(() => { + if (!cancelled) { + setIsLoadingPreview(false) + } + }) + + return () => { + cancelled = true + } + }, [activeTabId, rewindTarget]) + const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), [messages], ) + const closeRewindModal = useCallback(() => { + if (isExecutingRewind) return + setRewindTarget(null) + setRewindPreview(null) + setRewindError(null) + setIsLoadingPreview(false) + }, [isExecutingRewind]) + + const handleConfirmRewind = useCallback(async () => { + if (!activeTabId || !rewindTarget || isExecutingRewind) return + + setIsExecutingRewind(true) + setRewindError(null) + + try { + if (chatState !== 'idle') { + stopGeneration(activeTabId) + } + + const result = await sessionsApi.rewind(activeTabId, { + userMessageIndex: rewindTarget.userMessageIndex, + }) + + await reloadHistory(activeTabId) + queueComposerPrefill(activeTabId, { + text: rewindTarget.content, + attachments: rewindTarget.attachments, + }) + + addToast({ + type: 'success', + message: result.code.available + ? t('chat.rewindSuccessWithCode', { + count: result.conversation.messagesRemoved, + }) + : t('chat.rewindSuccessConversationOnly', { + count: result.conversation.messagesRemoved, + }), + }) + + setRewindTarget(null) + setRewindPreview(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) + setRewindError(message) + } finally { + setIsExecutingRewind(false) + } + }, [ + activeTabId, + addToast, + chatState, + isExecutingRewind, + queueComposerPrefill, + reloadHistory, + rewindTarget, + stopGeneration, + t, + ]) + + let visibleUserMessageIndex = -1 + return (
@@ -148,6 +279,10 @@ export function MessageList() { } const msg = item.message + const rewindableUserIndex = + msg.type === 'user_text' && !msg.pending + ? ++visibleUserMessageIndex + : null return ( { + setRewindTarget({ + userMessageIndex, + content: message.content, + attachments: message.attachments, + }) + } + : undefined + } /> ) })} @@ -180,6 +327,119 @@ export function MessageList() {
+ + + + + + } + > +
+
+
+ {t('chat.rewindPromptLabel')} +
+
+ {rewindTarget?.content || t('chat.rewindAttachmentOnly')} +
+
+ + {isLoadingPreview && ( +
+ {t('chat.rewindLoading')} +
+ )} + + {!isLoadingPreview && rewindPreview && ( +
+
+
+ history + {t('chat.rewindConversationCardTitle')} +
+

+ {t('chat.rewindConversationCardBody', { + count: rewindPreview.conversation.messagesRemoved, + })} +

+
+ +
+
+ code + {t('chat.rewindCodeCardTitle')} +
+ {rewindPreview.code.available ? ( +
+
{t('chat.rewindCodeFiles', { count: rewindPreview.code.filesChanged.length })}
+
{t('chat.rewindCodeInsertions', { count: rewindPreview.code.insertions })}
+
{t('chat.rewindCodeDeletions', { count: rewindPreview.code.deletions })}
+
+ ) : ( +

+ {rewindPreview.code.reason || t('chat.rewindCodeUnavailable')} +

+ )} +
+
+ )} + + {!isLoadingPreview && rewindPreview?.code.available && rewindPreview.code.filesChanged.length > 0 && ( +
+
+ {t('chat.rewindFilesLabel')} +
+
+ {rewindPreview.code.filesChanged.slice(0, 8).map((filePath) => ( + + {filePath} + + ))} + {rewindPreview.code.filesChanged.length > 8 && ( + + {t('chat.rewindFilesMore', { + count: rewindPreview.code.filesChanged.length - 8, + })} + + )} +
+
+ )} + + {rewindError && ( +
+ {rewindError} +
+ )} +
+
) } @@ -189,17 +449,35 @@ export const MessageBlock = memo(function MessageBlock({ activeThinkingId, agentTaskNotifications, toolResult, + rewindableUserIndex, + onRequestRewind, }: { message: UIMessage activeThinkingId: string | null agentTaskNotifications: Record toolResult?: { content: unknown; isError: boolean } | null + rewindableUserIndex?: number | null + onRequestRewind?: ( + message: Extract, + userMessageIndex: number, + ) => void }) { const t = useTranslation() switch (message.type) { case 'user_text': - return + return ( + onRequestRewind(message, rewindableUserIndex) + : undefined + } + rewindLabel={t('chat.rewindAction')} + /> + ) case 'assistant_text': return case 'thinking': diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 6135bbdd..58b7f98e 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -5,9 +5,11 @@ import { MessageActionBar } from './MessageActionBar' type Props = { content: string attachments?: UIAttachment[] + onRewind?: () => void + rewindLabel?: string } -export function UserMessage({ content, attachments }: Props) { +export function UserMessage({ content, attachments, onRewind, rewindLabel }: Props) { const hasText = content.trim().length > 0 return ( @@ -31,6 +33,8 @@ export function UserMessage({ content, attachments }: Props) { )}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 63e738e3..115d6bb2 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -324,6 +324,23 @@ export const en = { 'chat.select': 'select', 'chat.dismiss': 'dismiss', 'chat.stopTitle': 'Stop generation (Cmd+.)', + 'chat.rewindAction': 'Rewind to here', + 'chat.rewindModalTitle': 'Rewind Conversation', + 'chat.rewindConfirm': 'Rewind here', + 'chat.rewindPromptLabel': 'Selected prompt', + 'chat.rewindAttachmentOnly': 'This message only contains attachments.', + 'chat.rewindLoading': 'Inspecting file checkpoints for this turn...', + 'chat.rewindConversationCardTitle': 'Conversation rollback', + 'chat.rewindConversationCardBody': 'Remove this prompt and the {count} active messages that follow it, then reopen the composer at that point.', + 'chat.rewindCodeCardTitle': 'Code rollback', + 'chat.rewindCodeFiles': '{count} files will be restored', + 'chat.rewindCodeInsertions': '{count} insertions will be removed', + 'chat.rewindCodeDeletions': '{count} deletions will be restored', + 'chat.rewindCodeUnavailable': 'No file checkpoint is available for this prompt. The conversation can still be rewound.', + 'chat.rewindFilesLabel': 'Affected files', + '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.', // ─── Streaming Indicator ────────────────────────────────────── 'streaming.thinking': 'Thinking', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6e353804..c8c01c08 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -326,6 +326,23 @@ export const zh: Record = { 'chat.select': '选择', 'chat.dismiss': '关闭', 'chat.stopTitle': '停止生成 (Cmd+.)', + 'chat.rewindAction': '回滚到这里', + 'chat.rewindModalTitle': '回滚对话', + 'chat.rewindConfirm': '执行回滚', + 'chat.rewindPromptLabel': '目标提示词', + 'chat.rewindAttachmentOnly': '这条消息只包含附件。', + 'chat.rewindLoading': '正在检查这一轮的文件检查点...', + 'chat.rewindConversationCardTitle': '对话回滚', + 'chat.rewindConversationCardBody': '移除这条提示词以及其后的 {count} 条活跃消息,并把输入框恢复到这个位置。', + 'chat.rewindCodeCardTitle': '代码回滚', + 'chat.rewindCodeFiles': '将恢复 {count} 个文件', + 'chat.rewindCodeInsertions': '将移除 {count} 行新增', + 'chat.rewindCodeDeletions': '将恢复 {count} 行删除', + 'chat.rewindCodeUnavailable': '这条提示词没有可用的文件检查点,仍然可以只回滚对话。', + 'chat.rewindFilesLabel': '受影响文件', + 'chat.rewindFilesMore': '另有 {count} 个', + 'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。', + 'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。', // ─── Streaming Indicator ────────────────────────────────────── 'streaming.thinking': '思考中', diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 830b3256..b6227f95 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -7,10 +7,16 @@ export function isTauriRuntime() { export async function initializeDesktopServerUrl() { const fallbackUrl = getDefaultBaseUrl() + const queryUrl = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('serverUrl') + : null + const requestedUrl = queryUrl?.trim() || fallbackUrl if (!isTauriRuntime()) { - setBaseUrl(fallbackUrl) - return fallbackUrl + setBaseUrl(requestedUrl) + await waitForHealth(requestedUrl) + return requestedUrl } try { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 7bb3b724..d2329832 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -49,6 +49,11 @@ export type PerSessionState = { slashCommands: Array<{ name: string; description: string }> agentTaskNotifications: Record elapsedTimer: ReturnType | null + composerPrefill?: { + text: string + attachments?: UIAttachment[] + nonce: number + } | null } const DEFAULT_SESSION_STATE: PerSessionState = { @@ -68,6 +73,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, + composerPrefill: null, } function createDefaultSessionState(): PerSessionState { @@ -98,6 +104,11 @@ type ChatStore = { setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void stopGeneration: (sessionId: string) => void loadHistory: (sessionId: string) => Promise + reloadHistory: (sessionId: string) => Promise + queueComposerPrefill: ( + sessionId: string, + prefill: { text: string; attachments?: UIAttachment[] }, + ) => void clearMessages: (sessionId: string) => void handleServerMessage: (sessionId: string, msg: ServerMessage) => void } @@ -123,6 +134,17 @@ function updateSessionIn( return { ...sessions, [sessionId]: { ...session, ...updater(session) } } } +async function fetchAndMapSessionHistory(sessionId: string) { + const { messages } = await sessionsApi.getMessages(sessionId) + return { + rawMessages: messages, + uiMessages: mapHistoryMessagesToUiMessages(messages), + restoredNotifications: reconstructAgentNotifications(messages), + lastTodos: extractLastTodoWriteFromHistory(messages), + hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages), + } +} + export const useChatStore = create((set, get) => ({ sessions: {}, @@ -354,9 +376,12 @@ export const useChatStore = create((set, get) => ({ loadHistory: async (sessionId) => { try { - const { messages } = await sessionsApi.getMessages(sessionId) - const uiMessages = mapHistoryMessagesToUiMessages(messages) - const restoredNotifications = reconstructAgentNotifications(messages) + const { + uiMessages, + restoredNotifications, + lastTodos, + hasMessagesAfterTaskCompletion, + } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session || session.messages.length > 0) return state @@ -365,12 +390,13 @@ export const useChatStore = create((set, get) => ({ agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, })) } }) - const lastTodos = extractLastTodoWriteFromHistory(messages) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos) + } else { + useCLITaskStore.getState().setTasksFromTodos([]) } - if (hasUserMessagesAfterTaskCompletion(messages)) { + if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed() } } catch { @@ -378,6 +404,62 @@ export const useChatStore = create((set, get) => ({ } }, + reloadHistory: async (sessionId) => { + try { + const { + uiMessages, + restoredNotifications, + lastTodos, + hasMessagesAfterTaskCompletion, + } = await fetchAndMapSessionHistory(sessionId) + + set((state) => { + const session = state.sessions[sessionId] + if (!session) return state + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + return { + sessions: updateSessionIn(state.sessions, sessionId, () => ({ + messages: uiMessages, + agentTaskNotifications: restoredNotifications, + chatState: 'idle', + activeThinkingId: null, + activeToolUseId: null, + activeToolName: null, + streamingText: '', + streamingToolInput: '', + pendingPermission: null, + pendingComputerUsePermission: null, + elapsedTimer: null, + statusVerb: '', + })), + } + }) + + if (lastTodos && lastTodos.length > 0) { + useCLITaskStore.getState().setTasksFromTodos(lastTodos) + } else { + useCLITaskStore.getState().setTasksFromTodos([]) + } + if (hasMessagesAfterTaskCompletion) { + useCLITaskStore.getState().markCompletedAndDismissed() + } + } catch { + // Session may not have messages yet + } + }, + + queueComposerPrefill: (sessionId, prefill) => { + set((state) => ({ + sessions: updateSessionIn(state.sessions, sessionId, () => ({ + composerPrefill: { + text: prefill.text, + attachments: prefill.attachments, + nonce: Date.now(), + }, + })), + })) + }, + clearMessages: (sessionId) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) })) }, diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 7e049c06..8cbb9871 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -157,6 +157,7 @@ describe('ConversationService', () => { 'com.claude-code-haha.desktop', ) expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456') + expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1') }) test('uses bun entrypoint fallback on Windows dev mode', () => { diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index ece8e50e..b7bf564c 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -74,6 +74,22 @@ function makeSnapshotEntry(): Record { } } +function makeFileHistorySnapshotEntry( + snapshotMessageId: string, + trackedFileBackups: Record, +): Record { + return { + type: 'file-history-snapshot', + messageId: crypto.randomUUID(), + snapshot: { + messageId: snapshotMessageId, + trackedFileBackups, + timestamp: '2026-01-01T00:00:00.000Z', + }, + isSnapshotUpdate: false, + } +} + function makeUserEntry(content: string, uuid?: string): Record { return { parentUuid: null, @@ -126,6 +142,16 @@ function makeSessionMetaEntry(workDir: string): Record { } } +async function writeFileHistoryBackup( + sessionId: string, + backupFileName: string, + content: string, +): Promise { + const dir = path.join(tmpDir, 'file-history', sessionId) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, backupFileName), content, 'utf-8') +} + // ============================================================================ // SessionService tests // ============================================================================ @@ -785,6 +811,300 @@ describe('Sessions API', () => { ) }) + it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const firstUserId = crypto.randomUUID() + const firstAssistantId = crypto.randomUUID() + const secondUserId = crypto.randomUUID() + const secondAssistantId = crypto.randomUUID() + + await writeSessionFile('-tmp-api-test', sessionId, [ + makeSnapshotEntry(), + { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { role: 'user', content: 'first prompt' }, + uuid: firstUserId, + timestamp: '2026-01-01T00:01:00.000Z', + userType: 'external', + cwd: '/tmp/test', + sessionId, + }, + { + parentUuid: firstUserId, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'first reply' }], + }, + uuid: firstAssistantId, + timestamp: '2026-01-01T00:02:00.000Z', + }, + { + parentUuid: firstAssistantId, + isSidechain: false, + type: 'user', + message: { role: 'user', content: 'second prompt' }, + uuid: secondUserId, + timestamp: '2026-01-01T00:03:00.000Z', + userType: 'external', + cwd: '/tmp/test', + sessionId, + }, + { + parentUuid: secondUserId, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'second reply' }], + }, + uuid: secondAssistantId, + timestamp: '2026-01-01T00:04:00.000Z', + }, + ]) + + 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 previewBody = await previewRes.json() as { + conversation: { messagesRemoved: number } + code: { available: boolean } + } + expect(previewBody.conversation.messagesRemoved).toBe(2) + expect(previewBody.code.available).toBe(false) + + 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) + + const executeBody = await executeRes.json() as { + conversation: { messagesRemoved: number; removedMessageIds: string[] } + } + expect(executeBody.conversation.messagesRemoved).toBe(2) + expect(executeBody.conversation.removedMessageIds).toEqual([ + secondUserId, + secondAssistantId, + ]) + + const remainingMessages = await service.getSessionMessages(sessionId) + expect(remainingMessages.map((message) => message.id)).toEqual([ + firstUserId, + firstAssistantId, + ]) + }) + + it('POST /api/sessions/:id/rewind should restore a single edited file', async () => { + const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee' + const workDir = path.join(tmpDir, 'single-file-fixture') + const targetFile = path.join(workDir, 'src', 'app.js') + const userId = crypto.randomUUID() + const assistantId = crypto.randomUUID() + const backupName = 'single-file@v1' + + await fs.mkdir(path.dirname(targetFile), { recursive: true }) + await fs.writeFile( + targetFile, + "export const ORIGINAL_VALUE = 'after-rewind'\n", + 'utf-8', + ) + await writeFileHistoryBackup( + sessionId, + backupName, + "export const ORIGINAL_VALUE = 'before-rewind'\n", + ) + + await writeSessionFile('-tmp-api-single-file', sessionId, [ + makeSessionMetaEntry(workDir), + makeFileHistorySnapshotEntry(userId, { + 'src/app.js': { + backupFileName: backupName, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('edit app.js', userId), + cwd: workDir, + sessionId, + }, + { + ...makeAssistantEntry('DONE', userId), + uuid: assistantId, + }, + ]) + + const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 0, dryRun: true }), + }) + expect(previewRes.status).toBe(200) + const preview = await previewRes.json() as { + code: { available: boolean; filesChanged: string[] } + } + expect(preview.code.available).toBe(true) + expect(preview.code.filesChanged).toEqual([targetFile]) + + const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 0 }), + }) + expect(executeRes.status).toBe(200) + expect(await fs.readFile(targetFile, 'utf-8')).toBe( + "export const ORIGINAL_VALUE = 'before-rewind'\n", + ) + + const remainingMessages = await service.getSessionMessages(sessionId) + expect(remainingMessages).toHaveLength(0) + }) + + it('POST /api/sessions/:id/rewind should restore multiple files and remove created files', async () => { + const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee' + const workDir = path.join(tmpDir, 'multi-file-fixture') + const appFile = path.join(workDir, 'src', 'app.js') + const readmeFile = path.join(workDir, 'README.md') + const createdFile = path.join(workDir, 'notes', 'generated.txt') + const userId = crypto.randomUUID() + const backupApp = 'multi-app@v1' + const backupReadme = 'multi-readme@v1' + + await fs.mkdir(path.dirname(appFile), { recursive: true }) + await fs.mkdir(path.dirname(createdFile), { recursive: true }) + await fs.writeFile(appFile, "export const VALUE = 'edited'\n", 'utf-8') + await fs.writeFile(readmeFile, '# changed\n', 'utf-8') + await fs.writeFile(createdFile, 'new file\n', 'utf-8') + await writeFileHistoryBackup(sessionId, backupApp, "export const VALUE = 'original'\n") + await writeFileHistoryBackup(sessionId, backupReadme, '# original\n') + + await writeSessionFile('-tmp-api-multi-file', sessionId, [ + makeSessionMetaEntry(workDir), + makeFileHistorySnapshotEntry(userId, { + 'src/app.js': { + backupFileName: backupApp, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + 'README.md': { + backupFileName: backupReadme, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + 'notes/generated.txt': { + backupFileName: null, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('edit multiple files', userId), + cwd: workDir, + sessionId, + }, + makeAssistantEntry('DONE', userId), + ]) + + const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 0, dryRun: true }), + }) + expect(previewRes.status).toBe(200) + const preview = await previewRes.json() as { + code: { available: boolean; filesChanged: string[] } + } + expect(preview.code.available).toBe(true) + expect(preview.code.filesChanged.sort()).toEqual([ + appFile, + createdFile, + readmeFile, + ].sort()) + + const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userMessageIndex: 0 }), + }) + expect(executeRes.status).toBe(200) + + expect(await fs.readFile(appFile, 'utf-8')).toBe("export const VALUE = 'original'\n") + expect(await fs.readFile(readmeFile, 'utf-8')).toBe('# original\n') + await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('POST /api/sessions/:id/rewind should restore the previous version when rewinding the second edit of the same file', async () => { + const sessionId = 'dddddddd-bbbb-cccc-dddd-eeeeeeeeeeee' + const workDir = path.join(tmpDir, 'same-file-two-turns') + const targetFile = path.join(workDir, 'src', 'app.js') + const firstUserId = crypto.randomUUID() + const secondUserId = crypto.randomUUID() + const backupV1 = 'same-file@v1' + const backupV2 = 'same-file@v2' + + await fs.mkdir(path.dirname(targetFile), { recursive: true }) + await fs.writeFile(targetFile, "export const STEP = 'v2'\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-two-turns', sessionId, [ + makeSessionMetaEntry(workDir), + makeFileHistorySnapshotEntry(firstUserId, { + 'src/app.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/app.js': { + backupFileName: backupV2, + version: 2, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('make v2', secondUserId), + cwd: workDir, + sessionId, + }, + makeAssistantEntry('DONE', secondUserId), + ]) + + 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(targetFile, 'utf-8')).toBe("export const STEP = 'v1'\n") + + const remainingMessages = await service.getSessionMessages(sessionId) + expect(remainingMessages.map((message) => message.id)).toHaveLength(2) + expect(remainingMessages[0]?.id).toBe(firstUserId) + }) + // -------------------------------------------------------------------------- // Conversations API via /api/sessions/:id/chat // -------------------------------------------------------------------------- diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index beffa881..091e9fba 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -17,6 +17,10 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { getSlashCommands } from '../ws/handler.js' import { getCommandName } from '../../commands.js' import { getSkillDirCommands } from '../../skills/loadSkillsDir.js' +import { + executeSessionRewind, + previewSessionRewind, +} from '../services/sessionRewindService.js' export async function handleSessionsApi( req: Request, @@ -73,6 +77,16 @@ export async function handleSessionsApi( return await getGitInfo(sessionId) } + if (subResource === 'rewind') { + if (req.method !== 'POST') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await rewindSession(req, sessionId) + } + if (subResource === 'slash-commands') { if (req.method !== 'GET') { return Response.json( @@ -252,6 +266,25 @@ async function getGitInfo(sessionId: string): Promise { } } +async function rewindSession(req: Request, sessionId: string): Promise { + let body: { userMessageIndex?: number; dryRun?: boolean } + try { + body = (await req.json()) as { userMessageIndex?: number; dryRun?: boolean } + } catch { + throw ApiError.badRequest('Invalid JSON body') + } + + if (!Number.isInteger(body.userMessageIndex)) { + throw ApiError.badRequest('userMessageIndex (integer) is required') + } + + const result = body.dryRun + ? await previewSessionRewind(sessionId, body.userMessageIndex) + : await executeSessionRewind(sessionId, body.userMessageIndex) + + return Response.json(result) +} + async function patchSession(req: Request, sessionId: string): Promise { let body: { title?: string } try { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index c0706e13..e3cd80aa 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -543,6 +543,7 @@ export class ConversationService { return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', + CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', CALLER_DIR: workDir, PWD: workDir, ...(sdkUrl diff --git a/src/server/services/sessionRewindService.ts b/src/server/services/sessionRewindService.ts new file mode 100644 index 00000000..53054b5d --- /dev/null +++ b/src/server/services/sessionRewindService.ts @@ -0,0 +1,393 @@ +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 { ApiError } from '../middleware/errorHandler.js' +import { + type FileHistorySnapshot, +} from '../../utils/fileHistory.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { conversationService } from './conversationService.js' +import { sessionService } from './sessionService.js' + +type RewindTarget = { + targetUserMessageId: string + userMessageIndex: number + userMessageCount: number + messagesRemoved: number +} + +type RewindCodePreview = { + available: boolean + reason?: string + filesChanged: string[] + insertions: number + deletions: number +} + +export type SessionRewindPreview = { + target: { + userMessageIndex: number + userMessageCount: number + } + conversation: { + messagesRemoved: number + } + code: RewindCodePreview +} + +export type SessionRewindExecuteResult = SessionRewindPreview & { + conversation: SessionRewindPreview['conversation'] & { + removedMessageIds: string[] + } +} + +function normalizeDiffStats(diffStats: { + filesChanged?: string[] + insertions?: number + deletions?: number +} | undefined): RewindCodePreview { + return { + available: true, + filesChanged: diffStats?.filesChanged ?? [], + insertions: diffStats?.insertions ?? 0, + deletions: diffStats?.deletions ?? 0, + } +} + +async function resolveRewindTarget( + sessionId: string, + userMessageIndex: number, +): Promise { + const activeMessages = await sessionService.getSessionMessages(sessionId) + const userMessages = activeMessages.filter((message) => message.type === 'user') + + if (userMessages.length === 0) { + throw ApiError.badRequest('This session has no user messages to rewind.') + } + + if ( + !Number.isInteger(userMessageIndex) || + userMessageIndex < 0 || + userMessageIndex >= userMessages.length + ) { + throw ApiError.badRequest( + `Invalid userMessageIndex ${userMessageIndex}. Expected 0-${userMessages.length - 1}.`, + ) + } + + const targetUserMessage = userMessages[userMessageIndex] + if (!targetUserMessage) { + throw ApiError.badRequest('The selected user message could not be resolved.') + } + + const activeMessageIndex = activeMessages.findIndex( + (message) => message.id === targetUserMessage.id, + ) + + if (activeMessageIndex < 0) { + throw ApiError.badRequest('The selected user message is not in the active chain.') + } + + return { + targetUserMessageId: targetUserMessage.id, + userMessageIndex, + userMessageCount: userMessages.length, + messagesRemoved: activeMessages.length - activeMessageIndex, + } +} + +async function loadFileHistorySnapshots( + sessionId: string, +): Promise { + const snapshots = await sessionService.getSessionFileHistorySnapshots(sessionId) + if (snapshots.length === 0) { + return null + } + + return snapshots +} + +function expandTrackingPath(workDir: string, trackingPath: string): string { + return isAbsolute(trackingPath) ? trackingPath : join(workDir, trackingPath) +} + +function resolveBackupPath(sessionId: string, backupFileName: string): string { + return join(getClaudeConfigHomeDir(), 'file-history', sessionId, backupFileName) +} + +function collectTrackedPaths( + snapshots: FileHistorySnapshot[], +): Set { + const trackedPaths = new Set() + for (const snapshot of snapshots) { + for (const trackingPath of Object.keys(snapshot.trackedFileBackups)) { + trackedPaths.add(trackingPath) + } + } + return trackedPaths +} + +function findTargetSnapshot( + snapshots: FileHistorySnapshot[], + targetUserMessageId: string, +): FileHistorySnapshot | null { + return ( + snapshots.findLast((snapshot) => snapshot.messageId === (targetUserMessageId as UUID)) ?? + null + ) +} + +function getBackupFileNameFirstVersion( + trackingPath: string, + snapshots: FileHistorySnapshot[], +): string | null | undefined { + for (const snapshot of snapshots) { + const backup = snapshot.trackedFileBackups[trackingPath] + if (backup !== undefined && backup.version === 1) { + return backup.backupFileName + } + } + + return undefined +} + +async function readFileOrNull(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return null + } +} + +async function hasFileChanged( + filePath: string, + backupFilePath: string, +): Promise { + try { + const [currentStat, backupStat] = await Promise.all([ + stat(filePath), + stat(backupFilePath), + ]) + + if (currentStat.size !== backupStat.size) { + return true + } + + const [currentContent, backupContent] = await Promise.all([ + readFile(filePath), + readFile(backupFilePath), + ]) + return !currentContent.equals(backupContent) + } catch { + return true + } +} + +async function restoreBackupFile( + filePath: string, + backupFilePath: string, +): Promise { + const backupStats = await stat(backupFilePath) + try { + await copyFile(backupFilePath, filePath) + } catch (error) { + const maybeErr = error as NodeJS.ErrnoException + if (maybeErr.code !== 'ENOENT') throw error + await mkdir(dirname(filePath), { recursive: true }) + await copyFile(backupFilePath, filePath) + } + await chmod(filePath, backupStats.mode) +} + +async function buildCodePreview( + sessionId: string, + workDir: string, + targetUserMessageId: string, +): Promise<{ + snapshots: FileHistorySnapshot[] | null + preview: RewindCodePreview +}> { + const snapshots = await loadFileHistorySnapshots(sessionId) + if (!snapshots) { + return { + snapshots: null, + preview: { + available: false, + reason: 'No file checkpoints were recorded for this session.', + filesChanged: [], + insertions: 0, + deletions: 0, + }, + } + } + + const targetSnapshot = findTargetSnapshot(snapshots, targetUserMessageId) + if (!targetSnapshot) { + return { + snapshots, + preview: { + available: false, + reason: 'No file checkpoint is available for the selected message.', + filesChanged: [], + insertions: 0, + deletions: 0, + }, + } + } + + const trackedPaths = collectTrackedPaths(snapshots) + const filesChanged: string[] = [] + let insertions = 0 + let deletions = 0 + + for (const trackingPath of trackedPaths) { + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] + const backupFileName = + targetBackup?.backupFileName ?? + getBackupFileNameFirstVersion(trackingPath, snapshots) + + if (backupFileName === undefined) continue + + const absolutePath = expandTrackingPath(workDir, trackingPath) + + if (backupFileName === null) { + try { + await stat(absolutePath) + filesChanged.push(absolutePath) + } catch { + // File is already absent. + } + continue + } + + const backupFilePath = resolveBackupPath(sessionId, backupFileName) + if (!(await hasFileChanged(absolutePath, backupFilePath))) { + continue + } + + filesChanged.push(absolutePath) + const [currentContent, backupContent] = await Promise.all([ + readFileOrNull(absolutePath), + readFileOrNull(backupFilePath), + ]) + for (const change of diffLines(currentContent ?? '', backupContent ?? '')) { + if (change.added) { + insertions += change.count || 0 + } + if (change.removed) { + deletions += change.count || 0 + } + } + } + + return { + snapshots, + preview: normalizeDiffStats({ + filesChanged, + insertions, + deletions, + }), + } +} + +export async function previewSessionRewind( + sessionId: string, + userMessageIndex: number, +): Promise { + const target = await resolveRewindTarget(sessionId, userMessageIndex) + const workDir = + (conversationService.hasSession(sessionId) + ? conversationService.getSessionWorkDir(sessionId) + : null) || + (await sessionService.getSessionWorkDir(sessionId)) || + process.cwd() + const { preview } = await buildCodePreview( + sessionId, + workDir, + target.targetUserMessageId, + ) + + return { + target: { + userMessageIndex: target.userMessageIndex, + userMessageCount: target.userMessageCount, + }, + conversation: { + messagesRemoved: target.messagesRemoved, + }, + code: preview, + } +} + +export async function executeSessionRewind( + sessionId: string, + userMessageIndex: number, +): Promise { + const target = await resolveRewindTarget(sessionId, userMessageIndex) + const workDir = + (conversationService.hasSession(sessionId) + ? conversationService.getSessionWorkDir(sessionId) + : null) || + (await sessionService.getSessionWorkDir(sessionId)) || + process.cwd() + const { snapshots, preview } = await buildCodePreview( + sessionId, + workDir, + target.targetUserMessageId, + ) + + if (conversationService.hasSession(sessionId)) { + conversationService.stopSession(sessionId) + } + + if (preview.available && snapshots) { + const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId) + if (!targetSnapshot) { + throw ApiError.badRequest('No file checkpoint is available for the selected message.') + } + + for (const trackingPath of collectTrackedPaths(snapshots)) { + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] + const backupFileName = + targetBackup?.backupFileName ?? + getBackupFileNameFirstVersion(trackingPath, snapshots) + + if (backupFileName === undefined) continue + + const absolutePath = expandTrackingPath(workDir, trackingPath) + + if (backupFileName === null) { + try { + await unlink(absolutePath) + } catch (error) { + const maybeErr = error as NodeJS.ErrnoException + if (maybeErr.code !== 'ENOENT') throw error + } + continue + } + + await restoreBackupFile( + absolutePath, + resolveBackupPath(sessionId, backupFileName), + ) + } + } + + const trimResult = await sessionService.trimSessionMessagesFrom( + sessionId, + target.targetUserMessageId, + ) + + return { + target: { + userMessageIndex: target.userMessageIndex, + userMessageCount: target.userMessageCount, + }, + conversation: { + messagesRemoved: trimResult.removedCount, + removedMessageIds: trimResult.removedMessageIds, + }, + code: preview, + } +} diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 73bcab5d..7a6383cd 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -10,6 +10,7 @@ import * as path from 'node:path' import * as os from 'node:os' import { ApiError } from '../middleware/errorHandler.js' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' +import type { FileHistorySnapshot } from '../../utils/fileHistory.js' // ============================================================================ // Types @@ -38,6 +39,11 @@ export type SessionLaunchInfo = { customTitle: string | null } +export type TrimSessionResult = { + removedCount: number + removedMessageIds: string[] +} + export type MessageEntry = { id: string type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' @@ -53,6 +59,7 @@ export type MessageEntry = { type RawEntry = { type?: string uuid?: string + messageId?: string parentUuid?: string | null parent_tool_use_id?: string | null isSidechain?: boolean @@ -66,6 +73,11 @@ type RawEntry = { type?: string } timestamp?: string + snapshot?: { + messageId?: string + trackedFileBackups?: Record + timestamp?: string + } customTitle?: string title?: string [key: string]: unknown @@ -800,6 +812,87 @@ export class SessionService { } } + async trimSessionMessagesFrom( + sessionId: string, + startMessageId: string, + ): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + const entries = await this.readJsonlFile(found.filePath) + const activeMessages = this.entriesToMessages(entries) + const startIndex = activeMessages.findIndex((message) => message.id === startMessageId) + + if (startIndex < 0) { + throw ApiError.badRequest(`Message not found in active session chain: ${startMessageId}`) + } + + const removedMessageIds = activeMessages + .slice(startIndex) + .map((message) => message.id) + + if (removedMessageIds.length === 0) { + return { removedCount: 0, removedMessageIds: [] } + } + + const removedIds = new Set(removedMessageIds) + const filteredEntries = entries.filter( + (entry) => !(typeof entry.uuid === 'string' && removedIds.has(entry.uuid)), + ) + + const content = + filteredEntries.length > 0 + ? filteredEntries.map((entry) => JSON.stringify(entry)).join('\n') + '\n' + : '' + await fs.writeFile(found.filePath, content, 'utf-8') + + return { + removedCount: removedMessageIds.length, + removedMessageIds, + } + } + + async getSessionFileHistorySnapshots( + sessionId: string, + ): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + const entries = await this.readJsonlFile(found.filePath) + const snapshotsByMessageId = new Map() + + for (const entry of entries) { + if (entry.type !== 'file-history-snapshot' || !entry.snapshot) continue + + const snapshotMessageId = + typeof entry.snapshot.messageId === 'string' + ? entry.snapshot.messageId + : typeof entry.messageId === 'string' + ? entry.messageId + : null + + if (!snapshotMessageId) continue + + snapshotsByMessageId.set(snapshotMessageId, { + messageId: snapshotMessageId as FileHistorySnapshot['messageId'], + trackedFileBackups: + entry.snapshot.trackedFileBackups && + typeof entry.snapshot.trackedFileBackups === 'object' + ? (entry.snapshot.trackedFileBackups as FileHistorySnapshot['trackedFileBackups']) + : {}, + timestamp: new Date( + entry.snapshot.timestamp || entry.timestamp || new Date().toISOString(), + ), + }) + } + + return [...snapshotsByMessageId.values()] + } + // -------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------