+
{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 }) => (