diff --git a/README.md b/README.md index bb564f922..3ad0114ca 100644 --- a/README.md +++ b/README.md @@ -176,70 +176,6 @@ http://127.0.0.1:2024 --- -## 赞助与合作 - -本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。 - - - - - - - - - - - - - - - - - - -
赞助商介绍
- - 接口AI
- 接口AI -
-
- 感谢 接口AI 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 专属链接 注册并绑定 GitHub,可领取 3 美元优惠券。 -
- - 胜算云 - - - 感谢 胜算云 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 服务状态。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 专属链接 注册可获 10 元模力及首充 10% 赠送。 -
- -📧 **联系邮箱**:relakkes@gmail.com - ---- - -## ☕ 请作者喝杯咖啡 - -如果这个项目对您有帮助,欢迎打赏支持,您的每一份支持都是我持续更新的动力 ❤️ - - - - - - - -
-微信赞赏
-微信赞赏 -
-支付宝
-支付宝 -
- -Buy Me a Coffee -
-Buy Me a Coffee -
- ---- ## 技术栈 @@ -284,20 +220,10 @@ http://127.0.0.1:2024 --- -## ⭐ Star 趋势图 - -如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha! - - - - - - Star History Chart - - +## Disclaimer ---- +本仓库基于 [cc-haha](https://github.com/NanmiCoder/cc-haha) 进行二次开发,其中 Claude Code 原始源码的版权归 [Anthropic](https://www.anthropic.com) 所有,该部分仅可用于学习与研究用途。 -## Disclaimer +cc-haha 原项目的整体版权归其原作者所有;本仓库**自主修改与新增的代码部分**,允许任何人自由分发、修改与使用。 -本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。 +原项目地址:https://github.com/NanmiCoder/cc-haha 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/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 24e38544d..0c657252c 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -591,7 +591,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('Slash commands')).toBeInTheDocument() expect(screen.getByText('/clear')).toBeInTheDocument() expect(screen.getByText('/cost')).toBeInTheDocument() - expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument() + expect(screen.getByText('15 more commands available. Type / to search the full command list.')).toBeInTheDocument() resetPageStores() }) 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..93c02e083 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' @@ -537,6 +538,217 @@ 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 auto-scrolling when rendered content grows without a message state change', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + let resizeCallback: ResizeObserverCallback | null = null + const originalResizeObserver = globalThis.ResizeObserver + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + observe = vi.fn() + disconnect = vi.fn() + } + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: ResizeObserverMock, + }) + + try { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'tool_executing', + messages: [ + { + id: 'tool-bash', + type: 'tool_use', + toolName: 'Bash', + toolUseId: 'bash-1', + input: { command: 'printf "lots of output"' }, + timestamp: 1, + }, + ], + }), + }, + }) + + const { container } = render() + await waitFor(() => { + expect(resizeCallback).toBeTruthy() + }) + + 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(() => { + resizeCallback?.([], {} as ResizeObserver) + }) + + expect(scrollIntoView).toHaveBeenCalled() + } finally { + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: originalResizeObserver, + }) + } + }) + + it('renders automatic retry state as one live countdown notice', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + retry: { + paused: false, + failureCount: 2, + nextAttempt: 2, + intervalMs: 120_000, + nextRetryAt: Date.now() + 120_000, + errorMessage: 'API Error: Input is too long.', + errorCode: 'CLI_ERROR', + status: 'scheduled', + }, + }), + }, + }) + + render() + + const notice = screen.getByRole('status', { name: 'Automatic retry status' }) + expect(within(notice).getByText('Retrying in 2:00 (retry #2)')).toBeTruthy() + expect(within(notice).getByText('attempt 2')).toBeTruthy() + expect(within(notice).getByText('Last error code: CLI_ERROR')).toBeTruthy() + expect(within(notice).getByText('API Error: Input is too long.')).toBeTruthy() + + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(within(notice).getByText('Retrying in 1:59 (retry #2)')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('renders paused automatic retry state without transcript spam', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [], + retry: { + paused: true, + failureCount: 1, + nextAttempt: 1, + intervalMs: 120_000, + nextRetryAt: null, + errorMessage: 'API Error: overloaded', + errorCode: 'CLI_ERROR', + status: 'paused', + statusMessage: 'Automatic retry paused after stop.', + }, + }), + }, + }) + + render() + + const notice = screen.getByRole('status', { name: 'Automatic retry status' }) + expect(within(notice).getByText('Automatic retry paused after stop.')).toBeTruthy() + expect(within(notice).getByText('Use /retry resume to continue, /retry now to retry immediately, or /retry clear to clear this state.')).toBeTruthy() + expect(screen.queryByText('Retrying in')).toBeNull() + }) + it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { useChatStore.setState({ sessions: { @@ -652,10 +864,294 @@ 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('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: [], + }) + 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..477693234 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' @@ -18,7 +18,7 @@ import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' -import type { AgentTaskNotification, UIMessage } from '../../types/chat' +import type { AgentTaskNotification, AutoRetryState, UIMessage } from '../../types/chat' import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract @@ -49,6 +49,12 @@ type TurnChangeCardModel = { isLatest: boolean } +type RewindConfirmRequest = { + target: RewindTurnTarget + isLatest: boolean + source: 'message' | 'change-card' +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -173,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[], @@ -220,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 @@ -246,6 +282,7 @@ type MessageListProps = { } const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 +const AUTO_SCROLL_FRAME_PASSES = 2 function isNearScrollBottom(element: HTMLElement) { return ( @@ -254,6 +291,88 @@ function isNearScrollBottom(element: HTMLElement) { ) } +function scrollToElementBottom( + container: HTMLElement, + bottomElement: HTMLElement | null, +) { + bottomElement?.scrollIntoView?.({ behavior: 'auto', block: 'end' }) + container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight) +} + +function getRetryRemainingSeconds(retry: AutoRetryState, now: number) { + if (!retry.nextRetryAt) return 0 + return Math.max(0, Math.ceil((retry.nextRetryAt - now) / 1000)) +} + +function formatRetryCountdown(seconds: number) { + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return `${minutes}:${String(remainder).padStart(2, '0')}` + } + return `${seconds}s` +} + +function getRetryNoticeTitle(retry: AutoRetryState, now: number) { + if (retry.paused || retry.status === 'paused') { + return retry.statusMessage || 'Automatic retry paused' + } + + if (retry.status === 'attempting' || retry.status === 'resumed' || !retry.nextRetryAt) { + return `Retrying model request (retry #${retry.failureCount})` + } + + const remainingSeconds = getRetryRemainingSeconds(retry, now) + return `Retrying in ${formatRetryCountdown(remainingSeconds)} (retry #${retry.failureCount})` +} + +function AutoRetryNotice({ retry }: { retry: AutoRetryState }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (retry.paused || !retry.nextRetryAt) return + setNow(Date.now()) + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [retry.nextRetryAt, retry.paused]) + + const title = getRetryNoticeTitle(retry, now) + const helperText = retry.paused + ? 'Use /retry resume to continue, /retry now to retry immediately, or /retry clear to clear this state.' + : 'Use /retry error to view details, /retry pause to pause, or /retry now to retry immediately.' + + return ( +
+
+ + autorenew + +
+
+ {title} + + attempt {retry.failureCount} + +
+
+ Last error code: {retry.errorCode} +
+
+ {retry.errorMessage} +
+
+ {helperText} +
+
+
+
+ ) +} + export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId @@ -263,6 +382,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, ) @@ -271,10 +391,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const chatState = sessionState?.chatState ?? 'idle' const streamingText = sessionState?.streamingText ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null + const retry = sessionState?.retry ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) const bottomRef = useRef(null) const shouldAutoScrollRef = useRef(true) + const pendingAutoScrollFramesRef = useRef([]) const lastSessionIdRef = useRef(resolvedSessionId) const t = useTranslation() const [turnChangeCards, setTurnChangeCards] = useState([]) @@ -282,7 +405,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,22 +413,90 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { shouldAutoScrollRef.current = isNearScrollBottom(container) }, []) - useEffect(() => { + const clearPendingAutoScrollFrames = useCallback(() => { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + pendingAutoScrollFramesRef.current = [] + return + } + for (const frameId of pendingAutoScrollFramesRef.current) { + window.cancelAnimationFrame(frameId) + } + pendingAutoScrollFramesRef.current = [] + }, []) + + const performAutoScroll = useCallback(() => { + if (!shouldAutoScrollRef.current) return + const container = scrollContainerRef.current + if (!container) return + scrollToElementBottom(container, bottomRef.current) + }, []) + + const scheduleAutoScroll = useCallback(() => { + if (!shouldAutoScrollRef.current) return + + clearPendingAutoScrollFrames() + performAutoScroll() + + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return + } + + const scheduleFrame = (remainingPasses: number) => { + if (remainingPasses <= 0) return + const frameId = window.requestAnimationFrame(() => { + pendingAutoScrollFramesRef.current = pendingAutoScrollFramesRef.current.filter( + (id) => id !== frameId, + ) + performAutoScroll() + scheduleFrame(remainingPasses - 1) + }) + pendingAutoScrollFramesRef.current.push(frameId) + } + + scheduleFrame(AUTO_SCROLL_FRAME_PASSES) + }, [clearPendingAutoScrollFrames, performAutoScroll]) + + useLayoutEffect(() => { if (lastSessionIdRef.current !== resolvedSessionId) { shouldAutoScrollRef.current = true lastSessionIdRef.current = resolvedSessionId } - if (!shouldAutoScrollRef.current) return + scheduleAutoScroll() + }, [ + activeThinkingId, + agentTaskNotifications, + chatState, + messages, + resolvedSessionId, + retry, + scheduleAutoScroll, + streamingText, + turnChangeCards, + ]) + + useEffect(() => { + const content = scrollContentRef.current + if (!content || typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + scheduleAutoScroll() + }) + observer.observe(content) + + return () => { + observer.disconnect() + } + }, [resolvedSessionId, scheduleAutoScroll]) - bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) - }, [messages.length, resolvedSessionId, streamingText]) + useEffect(() => clearPendingAutoScrollFrames, [clearPendingAutoScrollFrames]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), [messages], ) const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages]) + const latestUserTurnTarget = useMemo(() => getLatestUserTurnTarget(messages), [messages]) const latestCompletedTurnId = completedTurnTargets.length > 0 ? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null @@ -314,11 +505,6 @@ 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], - ) - useEffect(() => { if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { setTurnChangeCards([]) @@ -384,9 +570,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,38 +606,93 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }) : t('chat.rewindSuccessConversationOnly', { count: result.conversation.messagesRemoved, - }), + }), }) - setTurnUndoConfirmTargetId(null) + 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]: getApiErrorMessage(error), + [target.messageId]: message, })) - setTurnUndoConfirmTargetId(null) + addToast({ + type: 'error', + message, + }) + setRewindConfirmRequest(null) } finally { setRewindingTurnId(null) } }, [ addToast, chatState, - confirmTurnCard, + discardLocalTurn, 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 (
-
+
{renderItems.map((item, index) => { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] @@ -468,21 +709,46 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { item.toolCalls.some((tc) => !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' && + latestUserTurnTarget?.messageId === item.message.id + ? latestUserTurnTarget + : null + const canRecallUserTurn = + Boolean(userTurnTarget) && + !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', + }) }} /> ))} @@ -515,6 +785,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { )} + {retry && } + {!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
{turnChangeLoadError} @@ -525,22 +797,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 +820,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 +841,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/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 2900a66a8..32448880c 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -38,6 +38,8 @@ describe('composerUtils', () => { expect.arrayContaining([ { name: 'help', description: 'Show available desktop and agent commands' }, { name: 'clear', description: 'Clear conversation history' }, + { name: 'goal', description: 'Set, view, pause, resume, or clear a persistent session goal' }, + { name: 'retry', description: 'View, pause, resume, retry now, or clear automatic model request retries' }, { name: 'context', description: 'Show current context usage' }, ]), ) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 7a8966904..d0655cc87 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -23,6 +23,8 @@ export const FALLBACK_SLASH_COMMANDS = [ ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, + { name: 'goal', description: 'Set, view, pause, resume, or clear a persistent session goal' }, + { name: 'retry', description: 'View, pause, resume, retry now, or clear automatic model request retries' }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 80ab9d755..3cb737b3e 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -806,7 +806,7 @@ describe('WorkspacePanel', () => { expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300') }) expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() - }) + }, 30_000) it('renders image previews from workspace files', async () => { await setWorkspaceState((state) => ({ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 45920002a..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 ────────────────────────────────────── @@ -823,6 +824,12 @@ 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.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 801f69aea..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 ────────────────────────────────────── @@ -825,6 +826,12 @@ export const zh: Record = { 'chat.select': '选择', 'chat.dismiss': '关闭', 'chat.stopTitle': '停止生成 (Cmd+.)', + 'chat.recallToEdit': '撤回', + 'chat.recallToEditAria': '撤回到编辑框', + '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 }) => (