From a23acfa5045b5be33c04a5f881b2912391499190 Mon Sep 17 00:00:00 2001 From: 2503637088 <2503637088@qq.com> Date: Sun, 10 May 2026 08:37:46 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=B7=9F=E9=9A=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/chat/ChatInput.test.tsx | 64 ++++ desktop/src/components/chat/ChatInput.tsx | 7 +- .../src/components/chat/MessageActionBar.tsx | 49 ++- .../src/components/chat/MessageList.test.tsx | 296 +++++++++++++++++- desktop/src/components/chat/MessageList.tsx | 167 +++++++--- desktop/src/components/chat/UserMessage.tsx | 27 +- desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + 8 files changed, 566 insertions(+), 54 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index cdba591d8..6ca9f235d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -428,4 +428,68 @@ describe('ChatInput file mentions', () => { attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }], }) }) + + it('restores recalled file attachments into the composer', async () => { + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + composerPrefill: { + text: 'Update this section', + nonce: 1, + attachments: [{ + type: 'file', + name: 'App.tsx', + path: 'src/App.tsx', + lineStart: 12, + lineEnd: 18, + note: 'tighten copy', + quote: '
', + }], + }, + }, + }, + }) + + render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + await waitFor(() => { + expect(input.value).toBe('Update this section') + }) + expect(screen.getByRole('button', { name: 'Remove App.tsx' })).toBeInTheDocument() + + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: 'Update this section', + attachments: [{ + type: 'file', + name: 'App.tsx', + path: 'src/App.tsx', + data: undefined, + mimeType: undefined, + lineStart: 12, + lineEnd: 18, + note: 'tighten copy', + quote: '
', + }], + }) + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 087efe077..b1581e07c 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -150,14 +150,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro setInput(composerPrefill.text) setAttachments( (composerPrefill.attachments ?? []) - .filter((attachment) => attachment.type === 'image' || attachment.data) + .filter((attachment) => attachment.type === 'image' || attachment.data || attachment.path) .map((attachment, index) => ({ id: `rewind-prefill-${composerPrefill.nonce}-${index}`, name: attachment.name, type: attachment.type, + path: attachment.path, mimeType: attachment.mimeType, previewUrl: attachment.type === 'image' ? attachment.data : undefined, data: attachment.data, + lineStart: attachment.lineStart, + lineEnd: attachment.lineEnd, + note: attachment.note, + quote: attachment.quote, })), ) setPlusMenuOpen(false) diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 3861bd40c..db4d67b22 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -1,19 +1,33 @@ import { CopyButton } from '../shared/CopyButton' +type MessageAction = { + label: string + displayLabel: string + onClick: () => void + disabled?: boolean + tone?: 'default' | 'danger' +} + type Props = { copyText?: string copyLabel: string align?: 'start' | 'end' + actions?: MessageAction[] } export function MessageActionBar({ copyText, copyLabel, align = 'start', + actions = [], }: Props) { const hasCopy = Boolean(copyText?.trim()) + const hasActions = actions.length > 0 + + if (!hasCopy && !hasActions) return null - if (!hasCopy) return null + const buttonClass = + '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 disabled:cursor-not-allowed disabled:opacity-50' return (
- + {hasCopy && ( + + )} + {actions.map((action) => ( + + ))}
) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index b1e75d320..c13a5a9d9 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -537,6 +537,77 @@ describe('MessageList nested tool calls', () => { expect(scrollIntoView).toHaveBeenCalled() }) + it('keeps auto-scrolling when an existing latest message grows while already near the bottom', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'thinking', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: 'Analyze this', + timestamp: 1, + }, + { + id: 'thinking-1', + type: 'thinking', + content: 'Considering', + timestamp: 2, + }, + ], + activeThinkingId: 'thinking-1', + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 552 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value + }, + }) + + scrollIntoView.mockClear() + fireEvent.scroll(scroller) + + act(() => { + useChatStore.setState((state) => { + const session = state.sessions[ACTIVE_TAB]! + return { + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...session, + messages: session.messages.map((message) => + message.id === 'thinking-1' && message.type === 'thinking' + ? { ...message, content: `${message.content} next step` } + : message, + ), + }, + }, + } + }) + }) + + await waitFor(() => { + expect(screen.getByText('Considering next step')).toBeTruthy() + }) + expect(scrollIntoView).toHaveBeenCalled() + }) + it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { useChatStore.setState({ sessions: { @@ -652,10 +723,233 @@ describe('MessageList nested tool calls', () => { render() - expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy() + fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' })) + const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' }) + expect( + within(dialog).getByText( + 'This will rewind the latest assistant response and restore tracked files for this turn.', + ), + ).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull() }) + it('recalls the latest completed turn into the composer and rewinds tracked files', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [], + }) + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-1', 'assistant-1'], + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 4, + deletions: 1, + }, + }) + 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: 'Update the app shell', + modelContent: '@"/repo/src/App.tsx" Update the app shell', + attachments: [{ + type: 'file', + name: 'App.tsx', + path: 'src/App.tsx', + lineStart: 4, + lineEnd: 8, + }], + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'Updated the app shell.', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Recall to edit' })) + + const dialog = await screen.findByRole('dialog', { name: 'Recall latest message?' }) + expect( + within(dialog).getByText( + 'This will remove the latest prompt and assistant response, restore tracked file changes for that turn, and place the prompt back in the composer.', + ), + ).toBeTruthy() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Recall to edit' })) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + expectedContent: '@"/repo/src/App.tsx" Update the app shell', + }) + }) + expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: 'Update the app shell', + attachments: [{ + type: 'file', + name: 'App.tsx', + path: 'src/App.tsx', + lineStart: 4, + lineEnd: 8, + }], + }) + }) + + it('recalls attachment-only user turns into the composer', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [], + }) + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-1', 'assistant-1'], + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 0, + }, + }) + const reloadHistory = vi.fn().mockResolvedValue(undefined) + const queueComposerPrefill = vi.fn() + const attachments = [{ + type: 'file' as const, + name: 'App.tsx', + path: 'src/App.tsx', + lineStart: 12, + lineEnd: 16, + }] + + useChatStore.setState({ + reloadHistory, + queueComposerPrefill, + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '', + attachments, + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'Updated the referenced file.', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Recall to edit' })) + fireEvent.click( + within(await screen.findByRole('dialog', { name: 'Recall latest message?' })) + .getByRole('button', { name: 'Recall to edit' }), + ) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + expectedContent: '', + }) + }) + expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: '', + attachments, + }) + }) + + it('surfaces recall failures when the server rejects the rewind', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [], + }) + vi.spyOn(sessionsApi, 'rewind').mockRejectedValue(new Error('checkpoint moved')) + 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: 'Try risky change', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'Done.', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Recall to edit' })) + fireEvent.click( + within(await screen.findByRole('dialog', { name: 'Recall latest message?' })) + .getByRole('button', { name: 'Recall to edit' }), + ) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + expectedContent: 'Try risky change', + }) + }) + expect(reloadHistory).not.toHaveBeenCalled() + expect(queueComposerPrefill).not.toHaveBeenCalled() + }) + it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => { vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index ce9f1f1a0..a17c46859 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react' +import { useRef, useEffect, useLayoutEffect, useMemo, memo, useState, useCallback } from 'react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' @@ -49,6 +49,12 @@ type TurnChangeCardModel = { isLatest: boolean } +type RewindConfirmRequest = { + target: RewindTurnTarget + isLatest: boolean + source: 'message' | 'change-card' +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -282,7 +288,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const [turnActionErrors, setTurnActionErrors] = useState>({}) const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false) const [rewindingTurnId, setRewindingTurnId] = useState(null) - const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState(null) + const [rewindConfirmRequest, setRewindConfirmRequest] = useState(null) const updateAutoScrollState = useCallback(() => { const container = scrollContainerRef.current @@ -290,7 +296,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { shouldAutoScrollRef.current = isNearScrollBottom(container) }, []) - useEffect(() => { + useLayoutEffect(() => { if (lastSessionIdRef.current !== resolvedSessionId) { shouldAutoScrollRef.current = true lastSessionIdRef.current = resolvedSessionId @@ -299,7 +305,15 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (!shouldAutoScrollRef.current) return bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) - }, [messages.length, resolvedSessionId, streamingText]) + }, [ + activeThinkingId, + agentTaskNotifications, + chatState, + messages, + resolvedSessionId, + streamingText, + turnChangeCards, + ]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), @@ -314,9 +328,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { () => buildTurnCardInsertionMap(renderItems, turnChangeCards), [renderItems, turnChangeCards], ) - const confirmTurnCard = useMemo( - () => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null, - [turnChangeCards, turnUndoConfirmTargetId], + const completedTurnTargetByMessageId = useMemo( + () => new Map(completedTurnTargets.map((target) => [target.messageId, target] as const)), + [completedTurnTargets], ) useEffect(() => { @@ -384,9 +398,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId]) const handleUndoCurrentTurn = useCallback(async () => { - if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return + if (!resolvedSessionId || !rewindConfirmRequest || rewindingTurnId) return - const target = confirmTurnCard.target + const target = rewindConfirmRequest.target setRewindingTurnId(target.messageId) setTurnActionErrors((current) => { if (!(target.messageId in current)) return current @@ -420,31 +434,68 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }) : t('chat.rewindSuccessConversationOnly', { count: result.conversation.messagesRemoved, - }), + }), }) - setTurnUndoConfirmTargetId(null) + setRewindConfirmRequest(null) } catch (error) { + const message = getApiErrorMessage(error) setTurnActionErrors((current) => ({ ...current, - [target.messageId]: getApiErrorMessage(error), + [target.messageId]: message, })) - setTurnUndoConfirmTargetId(null) + addToast({ + type: 'error', + message, + }) + setRewindConfirmRequest(null) } finally { setRewindingTurnId(null) } }, [ addToast, chatState, - confirmTurnCard, queueComposerPrefill, reloadHistory, resolvedSessionId, + rewindConfirmRequest, rewindingTurnId, stopGeneration, t, ]) + const getConfirmText = useCallback((request: RewindConfirmRequest | null) => { + if (!request) { + return { + title: '', + body: '', + confirmLabel: '', + } + } + + if (request.source === 'change-card') { + return { + title: request.isLatest + ? t('chat.turnChangesLatestConfirmTitle') + : t('chat.turnChangesHistoricalConfirmTitle'), + body: request.isLatest + ? t('chat.turnChangesLatestConfirmBody') + : t('chat.turnChangesHistoricalConfirmBody'), + confirmLabel: request.isLatest + ? t('chat.turnChangesLatestConfirmUndo') + : t('chat.turnChangesHistoricalConfirmUndo'), + } + } + + return { + title: t('chat.recallLatestConfirmTitle'), + body: t('chat.recallLatestConfirmBody'), + confirmLabel: t('chat.recallConfirmUndo'), + } + }, [t]) + + const confirmText = getConfirmText(rewindConfirmRequest) + return (
!toolResultMap.has(tc.toolUseId)) } /> - ) : ( - { - const result = toolResultMap.get(item.message.toolUseId) - return result ? { content: result.content, isError: result.isError } : null - })() - : null - } - /> - )} + ) : (() => { + const userTurnTarget = item.message.type === 'user_text' + ? completedTurnTargetByMessageId.get(item.message.id) ?? null + : null + const canRecallUserTurn = + Boolean(userTurnTarget) && + userTurnTarget?.messageId === latestCompletedTurnId && + chatState === 'idle' && + !isMemberSession + + return ( + { + const result = toolResultMap.get(item.message.toolUseId) + return result ? { content: result.content, isError: result.isError } : null + })() + : null + } + recallAction={canRecallUserTurn && userTurnTarget + ? { + label: t('chat.recallToEditAria'), + displayLabel: t('chat.recallToEdit'), + disabled: rewindingTurnId === userTurnTarget.messageId, + onRecall: () => { + setRewindConfirmRequest({ + target: userTurnTarget, + isLatest: true, + source: 'message', + }) + }, + } + : null} + /> + ) + })()} {resolvedSessionId && cardsForItem.map((card) => ( { - setTurnUndoConfirmTargetId(card.target.messageId) + setRewindConfirmRequest({ + target: card.target, + isLatest: card.isLatest, + source: 'change-card', + }) }} /> ))} @@ -525,22 +605,16 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
{ if (!rewindingTurnId) { - setTurnUndoConfirmTargetId(null) + setRewindConfirmRequest(null) } }} onConfirm={handleUndoCurrentTurn} - title={confirmTurnCard?.isLatest - ? t('chat.turnChangesLatestConfirmTitle') - : t('chat.turnChangesHistoricalConfirmTitle')} - body={confirmTurnCard?.isLatest - ? t('chat.turnChangesLatestConfirmBody') - : t('chat.turnChangesHistoricalConfirmBody')} - confirmLabel={confirmTurnCard?.isLatest - ? t('chat.turnChangesLatestConfirmUndo') - : t('chat.turnChangesHistoricalConfirmUndo')} + title={confirmText.title} + body={confirmText.body} + confirmLabel={confirmText.confirmLabel} cancelLabel={t('common.cancel')} confirmVariant="danger" loading={Boolean(rewindingTurnId)} @@ -554,11 +628,18 @@ export const MessageBlock = memo(function MessageBlock({ activeThinkingId, agentTaskNotifications, toolResult, + recallAction, }: { message: UIMessage activeThinkingId: string | null agentTaskNotifications: Record toolResult?: { content: unknown; isError: boolean } | null + recallAction?: { + label: string + displayLabel: string + disabled: boolean + onRecall: () => void + } | null }) { const t = useTranslation() @@ -568,6 +649,10 @@ export const MessageBlock = memo(function MessageBlock({ ) case 'assistant_text': diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 9162384e7..0159337ca 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -5,10 +5,22 @@ import { MessageActionBar } from './MessageActionBar' type Props = { content: string attachments?: UIAttachment[] + onRecall?: () => void + recallLabel?: string + recallDisplayLabel?: string + recallDisabled?: boolean } -export function UserMessage({ content, attachments }: Props) { +export function UserMessage({ + content, + attachments, + onRecall, + recallLabel = 'Recall to edit', + recallDisplayLabel = 'Recall', + recallDisabled = false, +}: Props) { const hasText = content.trim().length > 0 + const hasActions = Boolean(onRecall) return (
@@ -29,11 +41,20 @@ export function UserMessage({ content, attachments }: Props) {
)} - {hasText && ( + {(hasText || hasActions) && ( )} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 45920002a..e19d5d466 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -823,6 +823,11 @@ export const en = { 'chat.select': 'select', 'chat.dismiss': 'dismiss', 'chat.stopTitle': 'Stop generation (Cmd+.)', + 'chat.recallToEdit': 'Recall to edit', + 'chat.recallToEditAria': 'Recall to edit', + 'chat.recallLatestConfirmTitle': 'Recall latest message?', + 'chat.recallLatestConfirmBody': 'This will remove the latest prompt and assistant response, restore tracked file changes for that turn, and place the prompt back in the composer.', + 'chat.recallConfirmUndo': 'Recall to edit', 'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.', 'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.', 'chat.turnChangesTitle': '{count} files changed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 801f69aea..3dc4a545f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -825,6 +825,11 @@ export const zh: Record = { 'chat.select': '选择', 'chat.dismiss': '关闭', 'chat.stopTitle': '停止生成 (Cmd+.)', + 'chat.recallToEdit': '撤回', + 'chat.recallToEditAria': '撤回到编辑框', + 'chat.recallLatestConfirmTitle': '撤回最近消息?', + 'chat.recallLatestConfirmBody': '这会移除最近一条提示词和助手回复,恢复这一轮中被跟踪的文件变更,并把提示词放回编辑框。', + 'chat.recallConfirmUndo': '撤回并编辑', 'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。', 'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。', 'chat.turnChangesTitle': '{count} 个文件已更改', From e2b01cb56f4dad8a992b1c5e8b4d4940a8e09094 Mon Sep 17 00:00:00 2001 From: 2503637088 <2503637088@qq.com> Date: Sun, 10 May 2026 13:00:09 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(desktop):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=92=A4=E5=9B=9E/=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E8=BF=9B=E8=81=94=E7=BD=91=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/scripts/build-windows-x64.ps1 | 99 +++++++- .../src/__tests__/generalSettings.test.tsx | 14 ++ .../src/components/chat/MessageList.test.tsx | 62 +++++ desktop/src/components/chat/MessageList.tsx | 62 ++++- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- desktop/src/pages/Settings.tsx | 3 +- desktop/src/stores/chatStore.test.ts | 190 +++++++++++++++ desktop/src/stores/chatStore.ts | 182 +++++++++++++- desktop/src/stores/settingsStore.test.ts | 41 ++++ desktop/src/stores/settingsStore.ts | 14 +- desktop/src/types/settings.ts | 8 +- src/server/__tests__/proxy-transform.test.ts | 26 ++ .../proxy/transform/anthropicToOpenaiChat.ts | 10 +- src/server/proxy/transform/types.ts | 1 + src/services/api/claude.ts | 13 + src/tools/WebFetchTool/WebFetchTool.ts | 7 +- src/tools/WebFetchTool/utils.test.ts | 81 ++++++- src/tools/WebFetchTool/utils.ts | 94 ++++++-- src/tools/WebSearchTool/WebSearchTool.ts | 159 ++++++++----- src/tools/WebSearchTool/backend.test.ts | 171 ++++++++++++- src/tools/WebSearchTool/backend.ts | 225 ++++++++++++++++-- src/utils/__tests__/messages.test.ts | 112 +++++++++ src/utils/messages.ts | 66 +++++ src/utils/model/model.ts | 8 + src/utils/settings/types.ts | 4 +- 26 files changed, 1532 insertions(+), 128 deletions(-) create mode 100644 src/utils/__tests__/messages.test.ts diff --git a/desktop/scripts/build-windows-x64.ps1 b/desktop/scripts/build-windows-x64.ps1 index ffd72f684..05aee7b3d 100644 --- a/desktop/scripts/build-windows-x64.ps1 +++ b/desktop/scripts/build-windows-x64.ps1 @@ -141,6 +141,44 @@ function Resolve-OutputDirectory { return $PreferredPath } +function Remove-PathIfExists { + param([string]$Path) + + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Force -Recurse + } +} + +function Remove-AppBuildCache { + param([string]$ReleaseDir) + + if (-not (Test-Path -LiteralPath $ReleaseDir)) { + return + } + + Remove-PathIfExists -Path (Join-Path $ReleaseDir 'bundle') + Remove-PathIfExists -Path (Join-Path $ReleaseDir 'claude-code-desktop.exe') + + $buildDir = Join-Path $ReleaseDir 'build' + if (Test-Path -LiteralPath $buildDir) { + Get-ChildItem -LiteralPath $buildDir -Directory -Filter 'claude-code-desktop-*' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-PathIfExists -Path $_.FullName } + } + + $fingerprintDir = Join-Path $ReleaseDir '.fingerprint' + if (Test-Path -LiteralPath $fingerprintDir) { + Get-ChildItem -LiteralPath $fingerprintDir -Directory -Filter 'claude-code-desktop-*' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-PathIfExists -Path $_.FullName } + } + + $depsDir = Join-Path $ReleaseDir 'deps' + if (Test-Path -LiteralPath $depsDir) { + Get-ChildItem -LiteralPath $depsDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'claude_code_desktop-*' -or $_.Name -like 'libclaude_code_desktop-*' } | + ForEach-Object { Remove-PathIfExists -Path $_.FullName } + } +} + Assert-WindowsHost Assert-Command bun @@ -189,6 +227,47 @@ if ($env:SKIP_INSTALL -ne '1') { } } +Write-Step 'Cleaning stale frontend output, sidecar binaries, and Tauri app cache...' +Get-ChildItem -LiteralPath (Join-Path $desktopDir 'src-tauri\binaries') -Filter 'claude-sidecar-*' -File -ErrorAction SilentlyContinue | + ForEach-Object { Remove-PathIfExists -Path $_.FullName } +Remove-PathIfExists -Path (Join-Path $desktopDir 'dist') +Remove-PathIfExists -Path (Join-Path $desktopDir 'tsconfig.tsbuildinfo') + +$targetReleaseDir = Join-Path $tauriTargetDir "$targetTriple\release" +$fallbackReleaseDir = Join-Path $tauriTargetDir 'release' +if ($env:PRESERVE_TAURI_TARGET -eq '1') { + Write-Step 'PRESERVE_TAURI_TARGET=1: keeping Rust dependency cache, clearing app-specific artifacts only...' + Remove-AppBuildCache -ReleaseDir $targetReleaseDir + Remove-AppBuildCache -ReleaseDir $fallbackReleaseDir +} else { + Write-Step "Removing Tauri target cache for $targetTriple to force fresh embedded frontend assets..." + Remove-PathIfExists -Path (Join-Path $tauriTargetDir $targetTriple) + Remove-AppBuildCache -ReleaseDir $fallbackReleaseDir +} + +Write-Step 'Rebuilding frontend (tsc + vite)...' +Push-Location $desktopDir +try { + & bun run build + if ($LASTEXITCODE -ne 0) { + throw "[build-windows-x64] bun run build failed (exit $LASTEXITCODE)" + } +} finally { + Pop-Location +} + +Write-Step "Rebuilding sidecar for $targetTriple..." +Push-Location $desktopDir +try { + $env:TAURI_ENV_TARGET_TRIPLE = $targetTriple + & bun run build:sidecars + if ($LASTEXITCODE -ne 0) { + throw "[build-windows-x64] bun run build:sidecars failed (exit $LASTEXITCODE)" + } +} finally { + Pop-Location +} + $tauriBuildArgs = @( 'tauri', 'build', @@ -199,18 +278,20 @@ $tauriBuildArgs = @( '--ci' ) -$tempConfigPath = $null +$tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json' +$tempConfig = @{ + build = @{ + beforeBuildCommand = 'cmd /c exit /b 0' + } +} if (-not $env:TAURI_SIGNING_PRIVATE_KEY) { - $tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json' - $tempConfig = @{ - bundle = @{ - createUpdaterArtifacts = $false - } - } | ConvertTo-Json -Depth 10 - Set-Content -Path $tempConfigPath -Value $tempConfig -Encoding UTF8 + $tempConfig.bundle = @{ + createUpdaterArtifacts = $false + } Write-Step 'TAURI_SIGNING_PRIVATE_KEY not set, disabling updater artifacts for local build' - $tauriBuildArgs += @('--config', $tempConfigPath) } +Set-Content -Path $tempConfigPath -Value ($tempConfig | ConvertTo-Json -Depth 10) -Encoding UTF8 +$tauriBuildArgs += @('--config', $tempConfigPath) if ($null -ne $TauriArgs) { $remainingArgs = @($TauriArgs) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index e03dbe248..ba4106e07 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -255,6 +255,20 @@ describe('Settings > General tab', () => { }) }) + it('saves DuckDuckGo keyless WebSearch mode', () => { + render() + + fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByRole('button', { name: 'DuckDuckGo' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({ + mode: 'duckduckgo', + tavilyApiKey: '', + braveApiKey: '', + }) + }) + it('links to WebSearch provider API key dashboards', () => { render() diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c13a5a9d9..4ed5cf062 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, buildRenderModel } from './MessageList' import { relativizeWorkspacePath } from './CurrentTurnChangeCard' +import { ApiError } from '../../api/client' import { sessionsApi } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' @@ -900,6 +901,67 @@ describe('MessageList nested tool calls', () => { }) }) + it('recalls unsaved in-flight user turns locally when the server has no rewind target', async () => { + vi.spyOn(sessionsApi, 'rewind').mockRejectedValue( + new ApiError(404, { message: 'This session has no user messages to rewind.' }), + ) + const reloadHistory = vi.fn().mockResolvedValue(undefined) + const queueComposerPrefill = vi.fn() + const discardLocalTurn = vi.fn() + const stopGeneration = vi.fn() + const attachments = [{ + type: 'file' as const, + name: 'App.tsx', + path: 'src/App.tsx', + lineStart: 3, + lineEnd: 5, + }] + + useChatStore.setState({ + reloadHistory, + queueComposerPrefill, + discardLocalTurn, + stopGeneration, + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'thinking', + messages: [ + { + id: 'user-local', + type: 'user_text', + content: 'Update the unsaved turn', + attachments, + timestamp: 1, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Recall to edit' })) + fireEvent.click( + within(await screen.findByRole('dialog', { name: 'Recall latest message?' })) + .getByRole('button', { name: 'Recall to edit' }), + ) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-local', + userMessageIndex: 0, + expectedContent: 'Update the unsaved turn', + }) + }) + expect(stopGeneration).toHaveBeenCalledWith(ACTIVE_TAB) + expect(discardLocalTurn).toHaveBeenCalledWith(ACTIVE_TAB, 'user-local') + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: 'Update the unsaved turn', + attachments, + }) + expect(reloadHistory).not.toHaveBeenCalled() + }) + it('surfaces recall failures when the server rejects the rewind', async () => { vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ checkpoints: [], diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a17c46859..56cb0fde5 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -179,6 +179,25 @@ export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnT return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null } +export function getLatestUserTurnTarget(messages: UIMessage[]): RewindTurnTarget | null { + let userMessageIndex = -1 + let latestTarget: RewindTurnTarget | null = null + + for (const message of messages) { + if (message.type !== 'user_text' || message.pending) continue + userMessageIndex += 1 + latestTarget = { + messageId: message.id, + userMessageIndex, + content: message.content, + expectedContent: message.modelContent ?? message.content, + attachments: message.attachments, + } + } + + return latestTarget +} + function buildTurnCardInsertionMap( renderItems: RenderItem[], turnChangeCards: TurnChangeCardModel[], @@ -226,6 +245,17 @@ function getApiErrorMessage(error: unknown) { : String(error) } +function isLocalOnlyRewindMiss(error: unknown) { + if (!(error instanceof ApiError)) return false + const message = getApiErrorMessage(error) + return ( + error.status === 404 || + message.includes('This session has no user messages to rewind') || + message.includes('Invalid rewind target') || + message.includes('Message not found in active session chain') + ) +} + function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint { if (!value || typeof value !== 'object') return false const checkpoint = value as Partial @@ -269,6 +299,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const stopGeneration = useChatStore((s) => s.stopGeneration) const reloadHistory = useChatStore((s) => s.reloadHistory) const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) + const discardLocalTurn = useChatStore((s) => s.discardLocalTurn) const isMemberSession = useTeamStore((s) => resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, ) @@ -320,6 +351,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { [messages], ) const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages]) + const latestUserTurnTarget = useMemo(() => getLatestUserTurnTarget(messages), [messages]) const latestCompletedTurnId = completedTurnTargets.length > 0 ? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null @@ -328,11 +360,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { () => buildTurnCardInsertionMap(renderItems, turnChangeCards), [renderItems, turnChangeCards], ) - const completedTurnTargetByMessageId = useMemo( - () => new Map(completedTurnTargets.map((target) => [target.messageId, target] as const)), - [completedTurnTargets], - ) - useEffect(() => { if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { setTurnChangeCards([]) @@ -440,6 +467,20 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { setRewindConfirmRequest(null) } catch (error) { const message = getApiErrorMessage(error) + if (rewindConfirmRequest.source === 'message' && isLocalOnlyRewindMiss(error)) { + discardLocalTurn(resolvedSessionId, target.messageId) + queueComposerPrefill(resolvedSessionId, { + text: target.content, + attachments: target.attachments, + }) + addToast({ + type: 'success', + message: t('chat.recallLocalOnlySuccess'), + }) + setRewindConfirmRequest(null) + return + } + setTurnActionErrors((current) => ({ ...current, [target.messageId]: message, @@ -455,6 +496,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }, [ addToast, chatState, + discardLocalTurn, queueComposerPrefill, reloadHistory, resolvedSessionId, @@ -520,13 +562,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { } /> ) : (() => { - const userTurnTarget = item.message.type === 'user_text' - ? completedTurnTargetByMessageId.get(item.message.id) ?? null - : null + const userTurnTarget = + item.message.type === 'user_text' && + latestUserTurnTarget?.messageId === item.message.id + ? latestUserTurnTarget + : null const canRecallUserTurn = Boolean(userTurnTarget) && - userTurnTarget?.messageId === latestCompletedTurnId && - chatState === 'idle' && !isMemberSession return ( diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index e19d5d466..2c649e7db 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -665,6 +665,7 @@ export const en = { 'settings.general.webSearch.mode.auto': 'Auto', 'settings.general.webSearch.mode.tavily': 'Tavily', 'settings.general.webSearch.mode.brave': 'Brave', + 'settings.general.webSearch.mode.duckduckgo': 'DuckDuckGo', 'settings.general.webSearch.mode.anthropic': 'Claude', 'settings.general.webSearch.mode.disabled': 'Off', 'settings.general.webSearchTavilyKey': 'Tavily API key', @@ -675,7 +676,7 @@ export const en = { 'settings.general.webSearchBraveApiKeyLink': 'Get Brave Search API key', 'settings.general.webSearchTavilyFreeHint': 'Create an account and copy a key; the free tier includes 1000 credits.', 'settings.general.webSearchBraveFreeHint': 'Create an account to generate a Search API key with free usage for testing.', - 'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.', + 'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back through Tavily, Brave, and DuckDuckGo. DuckDuckGo is a keyless managed search option similar to OpenClaw.', 'settings.general.webSearchSave': 'Save', // ─── Empty Session ────────────────────────────────────── @@ -828,6 +829,7 @@ export const en = { 'chat.recallLatestConfirmTitle': 'Recall latest message?', 'chat.recallLatestConfirmBody': 'This will remove the latest prompt and assistant response, restore tracked file changes for that turn, and place the prompt back in the composer.', 'chat.recallConfirmUndo': 'Recall to edit', + 'chat.recallLocalOnlySuccess': 'Recalled the unsaved message into the composer.', 'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.', 'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.', 'chat.turnChangesTitle': '{count} files changed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 3dc4a545f..9fc0e75a9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -667,6 +667,7 @@ export const zh: Record = { 'settings.general.webSearch.mode.auto': '自动', 'settings.general.webSearch.mode.tavily': 'Tavily', 'settings.general.webSearch.mode.brave': 'Brave', + 'settings.general.webSearch.mode.duckduckgo': 'DuckDuckGo', 'settings.general.webSearch.mode.anthropic': 'Claude', 'settings.general.webSearch.mode.disabled': '关闭', 'settings.general.webSearchTavilyKey': 'Tavily API Key', @@ -677,7 +678,7 @@ export const zh: Record = { 'settings.general.webSearchBraveApiKeyLink': '获取 Brave Search API Key', 'settings.general.webSearchTavilyFreeHint': '注册账号即可复制 API Key,免费额度包含 1000 Credits。', 'settings.general.webSearchBraveFreeHint': '注册账号后可创建 Search API Key,免费额度可用于测试。', - 'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch,失败或非 Claude 模型时再使用 Tavily/Brave。', + 'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch,然后依次降级到 Tavily、Brave、DuckDuckGo。DuckDuckGo 是类似 OpenClaw 的无 Key 托管搜索选项。', 'settings.general.webSearchSave': '保存', // ─── Empty Session ────────────────────────────────────── @@ -830,6 +831,7 @@ export const zh: Record = { 'chat.recallLatestConfirmTitle': '撤回最近消息?', 'chat.recallLatestConfirmBody': '这会移除最近一条提示词和助手回复,恢复这一轮中被跟踪的文件变更,并把提示词放回编辑框。', 'chat.recallConfirmUndo': '撤回并编辑', + 'chat.recallLocalOnlySuccess': '已将尚未写入记录的消息撤回到编辑框。', 'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。', 'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。', 'chat.turnChangesTitle': '{count} 个文件已更改', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 14177398d..a5e5fe6e6 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1397,6 +1397,7 @@ function GeneralSettings() { { value: 'auto', label: t('settings.general.webSearch.mode.auto') }, { value: 'tavily', label: t('settings.general.webSearch.mode.tavily') }, { value: 'brave', label: t('settings.general.webSearch.mode.brave') }, + { value: 'duckduckgo', label: t('settings.general.webSearch.mode.duckduckgo') }, { value: 'anthropic', label: t('settings.general.webSearch.mode.anthropic') }, { value: 'disabled', label: t('settings.general.webSearch.mode.disabled') }, ] @@ -1606,7 +1607,7 @@ function GeneralSettings() {

{t('settings.general.webSearchTitle')}

{t('settings.general.webSearchDescription')}

-
+
{WEB_SEARCH_MODES.map(({ value, label }) => (