mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
86cd2b8d18
commit
044e9b6cc8
177
desktop/scripts/e2e-rewind-agent-browser.sh
Executable file
177
desktop/scripts/e2e-rewind-agent-browser.sh
Executable 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}"
|
||||
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '思考中',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' })) }))
|
||||
},
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
393
src/server/services/sessionRewindService.ts
Normal file
393
src/server/services/sessionRewindService.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user