mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix: remove legacy message rewind entry
The chat surface now relies on the current-turn changes card for file rollback, so the older per-message hover rewind affordance was removed to avoid two competing rollback models. Constraint: Current-turn undo still depends on the existing checkpoint rewind API. Rejected: Keep both rewind entry points | duplicate rollback affordances make the product harder to explain and test. Confidence: high Scope-risk: moderate Directive: Prefer adding rollback behavior through the current-turn change card instead of restoring per-message hover rewind. Tested: bun test src/server/__tests__/sessions.test.ts Tested: cd desktop && bun run test Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Browser E2E scripts were updated but not executed in this commit.
This commit is contained in:
parent
bcfe042fc8
commit
6d56f3c21d
@ -137,10 +137,10 @@ 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} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-undo-current-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
|
||||
@ -171,10 +171,10 @@ wait_for_file_contains "${README_FILE}" "Second turn touched README" 60
|
||||
wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null
|
||||
|
||||
${AB} eval "const buttons = [...document.querySelectorAll('button')].filter((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (buttons.length < 2) throw new Error('Expected at least two Rewind buttons, found ' + buttons.length); buttons[buttons.length - 1].click();"
|
||||
${AB} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-rewind-second-turn-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-undo-second-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "turn-one" "${APP_FILE}" \
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
@ -46,9 +46,14 @@ import { ToolInspection } from '../pages/ToolInspection'
|
||||
import { Sidebar } from '../components/layout/Sidebar'
|
||||
import { UserMessage } from '../components/chat/UserMessage'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Core rendering tests: content-only pages must render without crashing
|
||||
* and contain key structural elements from the prototype.
|
||||
@ -576,7 +581,8 @@ describe('Design system compliance', () => {
|
||||
html.includes('C47A5A') ||
|
||||
html.includes('8F482F') ||
|
||||
html.includes('var(--color-brand)') ||
|
||||
html.includes('bg-[var(--color-brand)]'),
|
||||
html.includes('bg-[var(--color-brand)]') ||
|
||||
html.includes('var(--gradient-btn-primary)'),
|
||||
).toBe(true)
|
||||
unmount()
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
const ACTIVE_TAB = 'active-tab'
|
||||
@ -31,6 +32,7 @@ const ACTIVE_TAB = 'active-tab'
|
||||
describe('AskUserQuestion', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({
|
||||
activeTabId: ACTIVE_TAB,
|
||||
tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session', status: 'idle' }],
|
||||
|
||||
@ -73,12 +73,14 @@ vi.mock('../../api/computerUse', () => ({
|
||||
}))
|
||||
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { ComputerUsePermissionModal } from './ComputerUsePermissionModal'
|
||||
|
||||
describe('ComputerUsePermissionModal', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
openSettingsMock.mockReset()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import '@testing-library/jest-dom'
|
||||
import { FileSearchMenu } from './FileSearchMenu'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { filesystemApi } from '../../api/filesystem'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
vi.mock('../../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
@ -15,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({
|
||||
describe('FileSearchMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('shows an explicit error when directory browsing is denied', async () => {
|
||||
|
||||
@ -3,22 +3,17 @@ import { CopyButton } from '../shared/CopyButton'
|
||||
type Props = {
|
||||
copyText?: string
|
||||
copyLabel: string
|
||||
onRewind?: () => void
|
||||
rewindLabel?: string
|
||||
align?: 'start' | 'end'
|
||||
}
|
||||
|
||||
export function MessageActionBar({
|
||||
copyText,
|
||||
copyLabel,
|
||||
onRewind,
|
||||
rewindLabel = 'Rewind to here',
|
||||
align = 'start',
|
||||
}: Props) {
|
||||
const hasCopy = Boolean(copyText?.trim())
|
||||
const hasRewind = Boolean(onRewind)
|
||||
|
||||
if (!hasCopy && !hasRewind) return null
|
||||
if (!hasCopy) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -29,27 +24,13 @@ export function MessageActionBar({
|
||||
}`}
|
||||
>
|
||||
<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-container-low)] 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-container-low)] 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"
|
||||
/>
|
||||
)}
|
||||
<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-container-low)] 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>
|
||||
)
|
||||
|
||||
@ -563,7 +563,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(assistantShell?.className).not.toContain('ml-10')
|
||||
})
|
||||
|
||||
it('opens a rewind preview modal for user messages', async () => {
|
||||
it('does not expose the old message-level rewind action', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
@ -575,11 +575,19 @@ describe('MessageList nested tool calls', () => {
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/example.ts'],
|
||||
insertions: 6,
|
||||
deletions: 2,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 4,
|
||||
deletions: 1,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
@ -588,74 +596,15 @@ describe('MessageList nested tool calls', () => {
|
||||
{
|
||||
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, {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
expectedContent: '回到这一步重做',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('confirms rewind with the selected message id and prompt guard', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一段',
|
||||
content: '做一个页面',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'ok',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二段',
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
@ -663,23 +612,8 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: 'Rewind to here' })
|
||||
fireEvent.click(buttons[1]!)
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Rewind here/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二段',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '第二段',
|
||||
attachments: undefined,
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a current-turn change card from checkpoint preview', async () => {
|
||||
|
||||
@ -19,8 +19,6 @@ import { StreamingIndicator } from './StreamingIndicator'
|
||||
import { InlineTaskSummary } from './InlineTaskSummary'
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Button } from '../shared/Button'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
@ -191,16 +189,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const t = useTranslation()
|
||||
const [rewindTarget, setRewindTarget] = useState<{
|
||||
messageId: string
|
||||
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)
|
||||
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
|
||||
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
|
||||
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
|
||||
@ -224,49 +212,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||
}, [messages.length, resolvedSessionId, streamingText])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSessionId || !rewindTarget) return
|
||||
|
||||
let cancelled = false
|
||||
setIsLoadingPreview(true)
|
||||
setRewindPreview(null)
|
||||
setRewindError(null)
|
||||
|
||||
void sessionsApi
|
||||
.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: rewindTarget.messageId,
|
||||
userMessageIndex: rewindTarget.userMessageIndex,
|
||||
expectedContent: rewindTarget.content,
|
||||
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
|
||||
}
|
||||
}, [resolvedSessionId, rewindTarget])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
@ -335,75 +280,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}
|
||||
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
|
||||
|
||||
const closeRewindModal = useCallback(() => {
|
||||
if (isExecutingRewind) return
|
||||
setRewindTarget(null)
|
||||
setRewindPreview(null)
|
||||
setRewindError(null)
|
||||
setIsLoadingPreview(false)
|
||||
}, [isExecutingRewind])
|
||||
|
||||
const handleConfirmRewind = useCallback(async () => {
|
||||
if (!resolvedSessionId || !rewindTarget || isExecutingRewind) return
|
||||
|
||||
setIsExecutingRewind(true)
|
||||
setRewindError(null)
|
||||
|
||||
try {
|
||||
if (chatState !== 'idle') {
|
||||
stopGeneration(resolvedSessionId)
|
||||
}
|
||||
|
||||
const result = await sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: rewindTarget.messageId,
|
||||
userMessageIndex: rewindTarget.userMessageIndex,
|
||||
expectedContent: rewindTarget.content,
|
||||
})
|
||||
|
||||
await reloadHistory(resolvedSessionId)
|
||||
queueComposerPrefill(resolvedSessionId, {
|
||||
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)
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
chatState,
|
||||
isExecutingRewind,
|
||||
queueComposerPrefill,
|
||||
reloadHistory,
|
||||
resolvedSessionId,
|
||||
rewindTarget,
|
||||
stopGeneration,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleUndoCurrentTurn = useCallback(async () => {
|
||||
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
|
||||
|
||||
@ -467,8 +343,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
t,
|
||||
])
|
||||
|
||||
let visibleUserMessageIndex = -1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
@ -494,10 +368,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}
|
||||
|
||||
const msg = item.message
|
||||
const rewindableUserIndex =
|
||||
msg.type === 'user_text' && !msg.pending
|
||||
? ++visibleUserMessageIndex
|
||||
: null
|
||||
return (
|
||||
<MessageBlock
|
||||
key={msg.id}
|
||||
@ -512,19 +382,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
})()
|
||||
: null
|
||||
}
|
||||
rewindableUserIndex={rewindableUserIndex}
|
||||
onRequestRewind={
|
||||
!isMemberSession
|
||||
? (message, userMessageIndex) => {
|
||||
setRewindTarget({
|
||||
messageId: message.id,
|
||||
userMessageIndex,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@ -563,119 +420,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
<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>
|
||||
|
||||
<ConfirmDialog
|
||||
open={currentTurnUndoConfirmOpen}
|
||||
onClose={() => {
|
||||
@ -700,18 +444,11 @@ 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()
|
||||
|
||||
@ -721,12 +458,6 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
<UserMessage
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
onRewind={
|
||||
typeof rewindableUserIndex === 'number' && onRequestRewind
|
||||
? () => onRequestRewind(message, rewindableUserIndex)
|
||||
: undefined
|
||||
}
|
||||
rewindLabel={t('chat.rewindAction')}
|
||||
/>
|
||||
)
|
||||
case 'assistant_text':
|
||||
|
||||
@ -5,11 +5,9 @@ import { MessageActionBar } from './MessageActionBar'
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: UIAttachment[]
|
||||
onRewind?: () => void
|
||||
rewindLabel?: string
|
||||
}
|
||||
|
||||
export function UserMessage({ content, attachments, onRewind, rewindLabel }: Props) {
|
||||
export function UserMessage({ content, attachments }: Props) {
|
||||
const hasText = content.trim().length > 0
|
||||
|
||||
return (
|
||||
@ -35,8 +33,6 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
|
||||
<MessageActionBar
|
||||
copyText={content}
|
||||
copyLabel="Copy prompt"
|
||||
onRewind={onRewind}
|
||||
rewindLabel={rewindLabel}
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -4,10 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { PermissionDialog } from './PermissionDialog'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
describe('chat blocks', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
@ -3,10 +3,12 @@ import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { UpdateChecker } from './UpdateChecker'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUpdateStore } from '../../stores/updateStore'
|
||||
|
||||
describe('UpdateChecker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
|
||||
@ -617,21 +617,6 @@ 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.',
|
||||
'chat.turnChangesCardLabel': 'Current turn changed files',
|
||||
|
||||
@ -619,21 +619,6 @@ 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} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesCardLabel': '当前轮次已更改文件',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user