Make desktop rewind restore both transcript state and file checkpoints

The desktop client already had a conversation-level rewind UI concept on the
CLI side, but the web/desktop surface lacked the protocol, session trimming,
and file checkpoint restore path needed to make rewind trustworthy. This change
adds a desktop-specific rewind API, wires the message-level UI affordance and
confirmation modal, enables SDK file checkpointing for desktop sessions, and
covers the restore path with service tests plus a real agent-browser workflow.

Constraint: Desktop sessions run the CLI in SDK/print mode, so file checkpointing had to be enabled explicitly for that path
Constraint: main branch is checked out in a separate worktree, so merge-back must happen from the primary worktree after commit
Rejected: UI-only rewind that only trims local state | would leave persisted transcript and disk state inconsistent after refresh
Rejected: Reuse getLastSessionLog as the sole snapshot source | active rewind must read file-history metadata directly from the session file
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop rewind keyed to persisted user-message order unless the UI model starts carrying stable transcript UUIDs end-to-end
Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/sessions.test.ts; desktop MessageList vitest; desktop tsc no-emit; live agent-browser E2E on isolated ports with file edit then rewind
Not-tested: Browser E2E matrix for multi-file and second-edit scenarios is still covered at service-test level rather than full UI level
This commit is contained in:
程序员阿江(Relakkes) 2026-04-21 21:34:41 +08:00
parent 86cd2b8d18
commit 044e9b6cc8
17 changed files with 1565 additions and 18 deletions

View File

@ -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}"

View File

@ -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<SessionRewindResponse>(`/api/sessions/${sessionId}/rewind`, body, {
timeout: 60_000,
})
},
}

View File

@ -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<GitInfo | null>(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)

View File

@ -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 (
<div className="shrink-0 pb-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
<div className="flex items-center gap-1.5">
{hasRewind && (
<button
type="button"
onClick={onRewind}
aria-label={rewindLabel}
title={rewindLabel}
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[14px]">undo</span>
<span className="hidden min-[920px]:inline">Rewind</span>
</button>
)}
{hasCopy && (
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
)}
</div>
</div>
)
}

View File

@ -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<PerSessionState> = {}): 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(<MessageList />)
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: {

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -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<HTMLDivElement>(null)
const t = useTranslation()
const [rewindTarget, setRewindTarget] = useState<{
userMessageIndex: number
content: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
} | null>(null)
const [rewindPreview, setRewindPreview] = useState<SessionRewindResponse | null>(null)
const [rewindError, setRewindError] = useState<string | null>(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 (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mx-auto max-w-[860px]">
@ -148,6 +279,10 @@ export function MessageList() {
}
const msg = item.message
const rewindableUserIndex =
msg.type === 'user_text' && !msg.pending
? ++visibleUserMessageIndex
: null
return (
<MessageBlock
key={msg.id}
@ -162,6 +297,18 @@ export function MessageList() {
})()
: null
}
rewindableUserIndex={rewindableUserIndex}
onRequestRewind={
!isMemberSession
? (message, userMessageIndex) => {
setRewindTarget({
userMessageIndex,
content: message.content,
attachments: message.attachments,
})
}
: undefined
}
/>
)
})}
@ -180,6 +327,119 @@ export function MessageList() {
<div ref={bottomRef} />
</div>
<Modal
open={Boolean(rewindTarget)}
onClose={closeRewindModal}
title={t('chat.rewindModalTitle')}
footer={
<>
<Button
variant="ghost"
onClick={closeRewindModal}
disabled={isExecutingRewind}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
void handleConfirmRewind()
}}
loading={isExecutingRewind}
disabled={isLoadingPreview || Boolean(rewindError)}
icon={
!isExecutingRewind ? (
<span className="material-symbols-outlined text-[16px]">undo</span>
) : undefined
}
>
{t('chat.rewindConfirm')}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('chat.rewindPromptLabel')}
</div>
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--color-text-primary)]">
{rewindTarget?.content || t('chat.rewindAttachmentOnly')}
</div>
</div>
{isLoadingPreview && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-sm text-[var(--color-text-secondary)]">
{t('chat.rewindLoading')}
</div>
)}
{!isLoadingPreview && rewindPreview && (
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">history</span>
{t('chat.rewindConversationCardTitle')}
</div>
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{t('chat.rewindConversationCardBody', {
count: rewindPreview.conversation.messagesRemoved,
})}
</p>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">code</span>
{t('chat.rewindCodeCardTitle')}
</div>
{rewindPreview.code.available ? (
<div className="space-y-1 text-sm text-[var(--color-text-secondary)]">
<div>{t('chat.rewindCodeFiles', { count: rewindPreview.code.filesChanged.length })}</div>
<div>{t('chat.rewindCodeInsertions', { count: rewindPreview.code.insertions })}</div>
<div>{t('chat.rewindCodeDeletions', { count: rewindPreview.code.deletions })}</div>
</div>
) : (
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{rewindPreview.code.reason || t('chat.rewindCodeUnavailable')}
</p>
)}
</div>
</div>
)}
{!isLoadingPreview && rewindPreview?.code.available && rewindPreview.code.filesChanged.length > 0 && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('chat.rewindFilesLabel')}
</div>
<div className="flex flex-wrap gap-2">
{rewindPreview.code.filesChanged.slice(0, 8).map((filePath) => (
<span
key={filePath}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
{filePath}
</span>
))}
{rewindPreview.code.filesChanged.length > 8 && (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]">
{t('chat.rewindFilesMore', {
count: rewindPreview.code.filesChanged.length - 8,
})}
</span>
)}
</div>
</div>
)}
{rewindError && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/22 px-4 py-3 text-sm text-[var(--color-error)]">
{rewindError}
</div>
)}
</div>
</Modal>
</div>
)
}
@ -189,17 +449,35 @@ export const MessageBlock = memo(function MessageBlock({
activeThinkingId,
agentTaskNotifications,
toolResult,
rewindableUserIndex,
onRequestRewind,
}: {
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
rewindableUserIndex?: number | null
onRequestRewind?: (
message: Extract<UIMessage, { type: 'user_text' }>,
userMessageIndex: number,
) => void
}) {
const t = useTranslation()
switch (message.type) {
case 'user_text':
return <UserMessage content={message.content} attachments={message.attachments} />
return (
<UserMessage
content={message.content}
attachments={message.attachments}
onRewind={
typeof rewindableUserIndex === 'number' && onRequestRewind
? () => onRequestRewind(message, rewindableUserIndex)
: undefined
}
rewindLabel={t('chat.rewindAction')}
/>
)
case 'assistant_text':
return <AssistantMessage content={message.content} />
case 'thinking':

View File

@ -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) {
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
/>
)}
</div>

View File

@ -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',

View File

@ -326,6 +326,23 @@ export const zh: Record<TranslationKey, string> = {
'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': '思考中',

View File

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

View File

@ -49,6 +49,11 @@ export type PerSessionState = {
slashCommands: Array<{ name: string; description: string }>
agentTaskNotifications: Record<string, AgentTaskNotification>
elapsedTimer: ReturnType<typeof setInterval> | 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<void>
reloadHistory: (sessionId: string) => Promise<void>
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<ChatStore>((set, get) => ({
sessions: {},
@ -354,9 +376,12 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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' })) }))
},

View File

@ -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', () => {

View File

@ -74,6 +74,22 @@ function makeSnapshotEntry(): Record<string, unknown> {
}
}
function makeFileHistorySnapshotEntry(
snapshotMessageId: string,
trackedFileBackups: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> {
return {
parentUuid: null,
@ -126,6 +142,16 @@ function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
}
}
async function writeFileHistoryBackup(
sessionId: string,
backupFileName: string,
content: string,
): Promise<void> {
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
// --------------------------------------------------------------------------

View File

@ -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<Response> {
}
}
async function rewindSession(req: Request, sessionId: string): Promise<Response> {
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<Response> {
let body: { title?: string }
try {

View File

@ -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

View File

@ -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<RewindTarget> {
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<FileHistorySnapshot[] | null> {
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<string> {
const trackedPaths = new Set<string>()
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<string | null> {
try {
return await readFile(filePath, 'utf-8')
} catch {
return null
}
}
async function hasFileChanged(
filePath: string,
backupFilePath: string,
): Promise<boolean> {
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<void> {
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<SessionRewindPreview> {
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<SessionRewindExecuteResult> {
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,
}
}

View File

@ -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<string, unknown>
timestamp?: string
}
customTitle?: string
title?: string
[key: string]: unknown
@ -800,6 +812,87 @@ export class SessionService {
}
}
async trimSessionMessagesFrom(
sessionId: string,
startMessageId: string,
): Promise<TrimSessionResult> {
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<FileHistorySnapshot[]> {
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<string, FileHistorySnapshot>()
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
// --------------------------------------------------------------------------