From a914ff18f9184b746369d09937e3251e5b259521 Mon Sep 17 00:00:00 2001 From: aEgoist <85651989@qq.com> Date: Tue, 2 Jun 2026 23:18:04 +0800 Subject: [PATCH 1/3] feat: add self-heal continuation (go-on) feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add selfHealContinue setting that auto-sends '继续' when assistant produces no real text output (empty/too-short reply) - Detect stall by checking last assistant text message < 20 chars - 10s cooldown to prevent rapid re-fire - Add toggle in Settings → Permissions page - Add i18n strings (en + zh-CN) - Also includes ongoing AgentLoop improvements Closes #self-heal --- src/agent/loop/AgentLoop.ts | 5 +- src/model/streaming/streamModel.ts | 53 +++++++++++++++++-- ui/src/components/chat-v2/ChatInterfaceV2.tsx | 1 + .../chat/hooks/useChatRealtimeHandlers.ts | 37 +++++++++++++ ui/src/components/chat/types/types.ts | 1 + ui/src/components/chat/utils/chatStorage.ts | 6 +++ .../view/tabs/PermissionsSettingsTab.tsx | 26 ++++++++- ui/src/i18n/locales/en/settings.json | 4 ++ ui/src/i18n/locales/zh-CN/settings.json | 4 ++ 9 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/agent/loop/AgentLoop.ts b/src/agent/loop/AgentLoop.ts index 755af3de..5330b049 100644 --- a/src/agent/loop/AgentLoop.ts +++ b/src/agent/loop/AgentLoop.ts @@ -840,7 +840,10 @@ export class AgentLoop { temperature: this.config.temperature, thinking: this.config.thinking, stream: true, - metadata: this.config.metadata, + metadata: { + ...this.config.metadata, + pilotdeck_session: input.sessionId, + }, cacheBreakpoints: prepared.cacheBreakpoints, }; } diff --git a/src/model/streaming/streamModel.ts b/src/model/streaming/streamModel.ts index c0481947..a1421525 100644 --- a/src/model/streaming/streamModel.ts +++ b/src/model/streaming/streamModel.ts @@ -29,7 +29,7 @@ export async function complete( const nonStreamingRequest = { ...request, stream: false }; const { provider } = validateModelRequest(nonStreamingRequest, config); const body = buildModelRequest(nonStreamingRequest, config); - const response = await sendProviderRequest(provider, body, false, options.fetch ?? fetch, options.signal); + const response = await sendProviderRequest(provider, body, false, options.fetch ?? fetch, options.signal, request.metadata); if (!response.ok) { const raw = await safeReadJson(response); @@ -74,7 +74,7 @@ export async function* streamModel( } let response: Response; try { - response = await sendProviderRequest(provider, body, true, options.fetch ?? fetch, options.signal); + response = await sendProviderRequest(provider, body, true, options.fetch ?? fetch, options.signal, currentRequest.metadata); } catch (error) { if (attempt < MAX_STREAM_RETRIES && isRetryableStreamError(error)) { await delay(1000 * (attempt + 1)); @@ -204,6 +204,7 @@ async function sendProviderRequest( stream: boolean, transport: ModelTransport, signal?: AbortSignal, + metadata?: Record, ): Promise { const controller = new AbortController(); const detachAbort = signal ? forwardAbort(signal, controller) : undefined; @@ -211,8 +212,9 @@ async function sendProviderRequest( ? setTimeout(() => controller.abort(), provider.timeoutMs) : undefined; - const finalBody = provider.extraBody - ? { ...(body as Record), ...provider.extraBody } + const resolvedExtraBody = resolveExtraBody(provider.extraBody, metadata); + const finalBody = resolvedExtraBody + ? { ...(body as Record), ...resolvedExtraBody } : body; try { @@ -354,3 +356,46 @@ function isAbortError(error: unknown): boolean { function joinUrl(base: string, path: string): string { return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; } + +/** + * Resolve `${variable}` template strings in extraBody values against the + * provided metadata record. Supports dot-path lookups like + * `${pilotdeck_session}` or `${user.id}`. + * + * Values that are not strings, or strings without template expressions, + * are returned unchanged. Missing variables resolve to an empty string. + */ +function resolveExtraBody( + extraBody: Record | undefined, + metadata: Record | undefined, +): Record | undefined { + if (!extraBody) return undefined; + + const resolved: Record = {}; + for (const [key, value] of Object.entries(extraBody)) { + if (typeof value === "string") { + resolved[key] = value.replace(/\$\{([^}]+)\}/g, (_match, path: string) => { + const trimmed = path.trim(); + const lookedUp = lookupMetadataPath(metadata, trimmed); + return lookedUp !== undefined ? String(lookedUp) : ""; + }); + } else { + resolved[key] = value; + } + } + return resolved; +} + +function lookupMetadataPath( + metadata: Record | undefined, + path: string, +): unknown { + if (!metadata) return undefined; + const segments = path.split("."); + let current: unknown = metadata; + for (const segment of segments) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[segment]; + } + return current; +} diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 93f0ac10..aa4d53b0 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -291,6 +291,7 @@ function ChatInterfaceV2({ onNavigateToSession, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, + sendMessage, }); useEffect(() => { diff --git a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts index f7b390ea..5fe4e2e4 100644 --- a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -5,6 +5,8 @@ import type { Project, ProjectSession, SessionProvider } from '../../../types/ap import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import { useWebSocket } from '../../../contexts/WebSocketContext'; import { SmoothTextStream } from './streamSmoother'; +import { getPilotDeckSettings } from '../utils/chatStorage'; +import { startSessionCommand } from '../utils/sessionLauncher'; type PendingViewSession = { sessionId: string | null; @@ -120,6 +122,7 @@ interface UseChatRealtimeHandlersArgs { onNavigateToSession?: (sessionId: string) => void; onWebSocketReconnect?: () => void; sessionStore: SessionStore; + sendMessage: (message: unknown) => void; } /* ------------------------------------------------------------------ */ @@ -128,6 +131,7 @@ interface UseChatRealtimeHandlersArgs { export function useChatRealtimeHandlers({ provider, + selectedProject, selectedSession, currentSessionId, setCurrentSessionId, @@ -146,11 +150,13 @@ export function useChatRealtimeHandlers({ onNavigateToSession, onWebSocketReconnect, sessionStore, + sendMessage, }: UseChatRealtimeHandlersArgs) { const { subscribe } = useWebSocket(); const streamBySessionRef = useRef(new Map()); const thinkingBySessionRef = useRef(new Map()); + const lastSelfHealAtRef = useRef(0); const handleMessage = useCallback((latestMessage: LatestChatMessage, fallbackSessionId?: string | null) => { if (!latestMessage) return; @@ -428,6 +434,35 @@ export function useChatRealtimeHandlers({ setTimeout(() => window.refreshProjects?.(), 500); } } + + // Self-heal: if the assistant produced no real text output, send "继续" + if (sid && !msg.aborted) { + try { + const now = Date.now(); + const cooledDown = now - lastSelfHealAtRef.current >= 10_000; + if (cooledDown) { + const settings = getPilotDeckSettings(); + if (settings.selfHealContinue && selectedProject) { + const allMsgs = sessionStore.getMessages(sid); + const lastAssistant = [...allMsgs] + .reverse() + .find((m) => m.kind === 'text' && m.role === 'assistant'); + const content = lastAssistant?.content?.trim() ?? ''; + if (content.length < 20) { + lastSelfHealAtRef.current = now; + startSessionCommand({ + sendMessage, + selectedProject, + command: '继续', + sessionId: sid, + }); + } + } + } + } catch { + // self-heal must never break the normal flow + } + } break; } @@ -537,6 +572,8 @@ export function useChatRealtimeHandlers({ onNavigateToSession, onWebSocketReconnect, sessionStore, + selectedProject, + sendMessage, ]); useEffect(() => { diff --git a/ui/src/components/chat/types/types.ts b/ui/src/components/chat/types/types.ts index f2b9340a..fe18ed19 100644 --- a/ui/src/components/chat/types/types.ts +++ b/ui/src/components/chat/types/types.ts @@ -142,6 +142,7 @@ export interface PilotDeckSettings { disallowedTools: string[]; skipPermissions: boolean; projectSortOrder: string; + selfHealContinue?: boolean; lastUpdated?: string; [key: string]: unknown; } diff --git a/ui/src/components/chat/utils/chatStorage.ts b/ui/src/components/chat/utils/chatStorage.ts index 07025033..3c95882c 100644 --- a/ui/src/components/chat/utils/chatStorage.ts +++ b/ui/src/components/chat/utils/chatStorage.ts @@ -72,6 +72,10 @@ export function getPilotDeckSettings(): PilotDeckSettings { ? parsed.skipPermissions : false, projectSortOrder: parsed.projectSortOrder || 'name', + selfHealContinue: + typeof parsed.selfHealContinue === 'boolean' + ? parsed.selfHealContinue + : false, }; } catch { return { @@ -79,6 +83,7 @@ export function getPilotDeckSettings(): PilotDeckSettings { disallowedTools: [], skipPermissions: false, projectSortOrder: 'name', + selfHealContinue: false, }; } } @@ -122,5 +127,6 @@ function mergePermissionSettings(value: unknown): PilotDeckSettings { disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], skipPermissions: Boolean(parsed.skipPermissions), projectSortOrder: current.projectSortOrder || 'name', + selfHealContinue: typeof parsed.selfHealContinue === 'boolean' ? parsed.selfHealContinue : current.selfHealContinue, }; } diff --git a/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx b/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx index bf85affc..163f7e98 100644 --- a/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx +++ b/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { AlertTriangle, Download, Plus, Shield, Upload, X } from 'lucide-react'; +import { AlertTriangle, Download, Plus, RefreshCw, Shield, Upload, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Input } from '../../../../shared/view/ui'; import { isImeEnterEvent } from '../../../../utils/ime'; @@ -173,6 +173,7 @@ export default function PermissionsSettingsTab() { const [allowedTools, setAllowedTools] = useState([]); const [disallowedTools, setDisallowedTools] = useState([]); const [skipPermissions, setSkipPermissions] = useState(false); + const [selfHealContinue, setSelfHealContinue] = useState(false); const [newAllowed, setNewAllowed] = useState(''); const [newBlocked, setNewBlocked] = useState(''); const [banner, setBanner] = useState(null); @@ -183,6 +184,7 @@ export default function PermissionsSettingsTab() { setAllowedTools(settings.allowedTools); setDisallowedTools(settings.disallowedTools); setSkipPermissions(settings.skipPermissions); + setSelfHealContinue(settings.selfHealContinue || false); }, []); useEffect(() => { @@ -193,6 +195,7 @@ export default function PermissionsSettingsTab() { setAllowedTools(settings.allowedTools); setDisallowedTools(settings.disallowedTools); setSkipPermissions(settings.skipPermissions); + setSelfHealContinue(settings.selfHealContinue || false); }) .catch((error) => { console.error('Failed to load permission settings from backend:', error); @@ -442,6 +445,27 @@ export default function PermissionsSettingsTab() { })} ) : null} + + + {t('permissions.selfHealContinue.title', { defaultValue: 'Self-heal continuation' })} + + } + description={t('permissions.selfHealContinue.description', { + defaultValue: + 'When the assistant produces no real text output (e.g. empty response, tool-call-only turn), automatically send "继续" to unblock the session. Prevents silent stalls caused by model API failures.', + })} + > + { + setSelfHealContinue(value); + persist({ selfHealContinue: value }); + }} + /> + diff --git a/ui/src/i18n/locales/en/settings.json b/ui/src/i18n/locales/en/settings.json index 36a451c2..f3369ec7 100644 --- a/ui/src/i18n/locales/en/settings.json +++ b/ui/src/i18n/locales/en/settings.json @@ -390,6 +390,10 @@ "description": "Run tool calls without asking for confirmation. This maps to bypassPermissions and should only be used in trusted workspaces.", "warning": "Permission prompts are currently bypassed. Allowed and blocked rules below are still saved, but this global mode lets the agent run without asking." }, + "selfHealContinue": { + "title": "Self-heal continuation", + "description": "When the assistant produces no real text output (e.g. empty response, tool-call-only turn), automatically send \"继续\" to unblock the session. Prevents silent stalls caused by model API failures." + }, "allowedTools": { "title": "Allowed Tools", "description": "Tools that are automatically allowed without prompting for permission", diff --git a/ui/src/i18n/locales/zh-CN/settings.json b/ui/src/i18n/locales/zh-CN/settings.json index 75e9c564..5a9aaf97 100644 --- a/ui/src/i18n/locales/zh-CN/settings.json +++ b/ui/src/i18n/locales/zh-CN/settings.json @@ -390,6 +390,10 @@ "description": "工具调用不再弹出确认,等同于 bypassPermissions。仅建议在可信工作区中开启。", "warning": "当前已跳过权限确认。下方允许/禁用规则仍会保存,但这个全局模式会让智能体无需询问即可运行工具。" }, + "selfHealContinue": { + "title": "自愈继续", + "description": "当智能体没有产出有效文本回复时(如空回复、仅工具调用),自动发送「继续」解除卡死。用于防止模型 API 故障导致的会话静默中断。" + }, "allowedTools": { "title": "允许的工具", "description": "无需权限提示即可自动使用的工具", From daf6ba93cad6bb8aa3e4e993c394137d4b88fc85 Mon Sep 17 00:00:00 2001 From: aEgoist <85651989@qq.com> Date: Tue, 2 Jun 2026 23:38:33 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20auto-proceed=20=E2=80=94=20session-?= =?UTF-8?q?level=20automatic=20advancement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-session auto-proceed toggle via sidebar dot (green=on, gray=off) - Global autoProceedOn setting (default true) controls new sessions - Customizable autoProceedPrompt in Settings - After each assistant reply, auto-injects prompt for re-check unless reply contains '无需自动推进' - Sidebar dot: clickable 6x6px area, toggles between emerald/gray - Settings UI: toggle + textarea for prompt Closes #auto-proceed --- ui/src/components/app-shell/SidebarV2.tsx | 38 ++++++++++++--- .../chat/hooks/useChatRealtimeHandlers.ts | 26 ++++++++++ ui/src/components/chat/types/types.ts | 5 ++ .../chat/utils/autoProceedStorage.ts | 45 +++++++++++++++++ ui/src/components/chat/utils/chatStorage.ts | 17 +++++++ .../view/tabs/PermissionsSettingsTab.tsx | 48 +++++++++++++++++++ ui/src/i18n/locales/en/settings.json | 9 ++++ ui/src/i18n/locales/zh-CN/settings.json | 9 ++++ 8 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 ui/src/components/chat/utils/autoProceedStorage.ts diff --git a/ui/src/components/app-shell/SidebarV2.tsx b/ui/src/components/app-shell/SidebarV2.tsx index 7da2085f..ce1cd554 100644 --- a/ui/src/components/app-shell/SidebarV2.tsx +++ b/ui/src/components/app-shell/SidebarV2.tsx @@ -33,6 +33,7 @@ import { useCustomNamesVersion, } from '../../lib/customNames'; import pilotdeckLogoDark from '../../assets/pilotdeck-wordmark-dark.png'; +import { getAutoProceed, toggleAutoProceed } from '../chat/utils/autoProceedStorage'; import pilotdeckLogoLight from '../../assets/pilotdeck-wordmark-light.png'; const asTimestamp = (value: unknown): number => { @@ -155,9 +156,13 @@ const SPINNER_DOTS = Array.from({ length: 8 }, (_, index) => index); function SessionStatusIndicator({ status, label, + autoProceedActive = false, + onToggleAutoProceed, }: { status: SessionIndicatorStatus; label: string; + autoProceedActive?: boolean; + onToggleAutoProceed?: () => void; }) { if (status === 'processing') { return ( @@ -180,15 +185,30 @@ function SessionStatusIndicator({ ); } + const dotColor = + status === 'unread' + ? 'bg-blue-500 dark:bg-blue-400' + : autoProceedActive + ? 'bg-emerald-600 dark:bg-emerald-500' + : 'bg-neutral-300 dark:bg-neutral-600'; + + const toggleLabel = autoProceedActive + ? 'Auto-proceed on — click to disable' + : 'Auto-proceed off — click to enable'; + return ( - { + event.stopPropagation(); + onToggleAutoProceed?.(); + }} className={cn( 'block h-1.5 w-1.5 rounded-full', - status === 'unread' - ? 'bg-blue-500 dark:bg-blue-400' - : 'bg-neutral-300 dark:bg-neutral-600', + 'ring-offset-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + dotColor, )} /> ); @@ -277,6 +297,7 @@ export default function SidebarV2({ const [contextMenu, setContextMenu] = useState(null); const [collapsedSessionProjects, setCollapsedSessionProjects] = useState>(new Set()); const [draftSessionProjectName, setDraftSessionProjectName] = useState(null); + const [autoProceedVersion, setAutoProceedVersion] = useState(0); const renameInputRef = useRef(null); // Segmented toggle between the Projects list and the General workspace. @@ -767,6 +788,11 @@ export default function SidebarV2({ { + toggleAutoProceed(sessionId); + setAutoProceedVersion((v) => v + 1); + }} />
diff --git a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts index 5fe4e2e4..38f9b5dc 100644 --- a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -7,6 +7,7 @@ import { useWebSocket } from '../../../contexts/WebSocketContext'; import { SmoothTextStream } from './streamSmoother'; import { getPilotDeckSettings } from '../utils/chatStorage'; import { startSessionCommand } from '../utils/sessionLauncher'; +import { getAutoProceed } from '../utils/autoProceedStorage'; type PendingViewSession = { sessionId: string | null; @@ -462,6 +463,31 @@ export function useChatRealtimeHandlers({ } catch { // self-heal must never break the normal flow } + + // Auto-proceed: if active for this session, re-check and push forward + if (getAutoProceed(sid)) { + try { + const settings = getPilotDeckSettings(); + const allMsgs = sessionStore.getMessages(sid); + const lastAssistant = [...allMsgs] + .reverse() + .find((m) => m.kind === 'text' && m.role === 'assistant'); + const content = lastAssistant?.content?.trim() ?? ''; + if (!content.includes('无需自动推进') && content.length >= 20) { + const prompt = + settings.autoProceedPrompt || + '你处于自动推进模式,再次自行审查代码是否符合项目规范、是否已完整满足我的需求、是否已足够简洁清晰、无过度设计或冗余保护。如果你认为已达最终交付状态,请明确答复"无需自动推进"'; + startSessionCommand({ + sendMessage, + selectedProject, + command: prompt, + sessionId: sid, + }); + } + } catch { + // auto-proceed must never break the normal flow + } + } } break; } diff --git a/ui/src/components/chat/types/types.ts b/ui/src/components/chat/types/types.ts index fe18ed19..611810ca 100644 --- a/ui/src/components/chat/types/types.ts +++ b/ui/src/components/chat/types/types.ts @@ -137,12 +137,17 @@ export interface PilotDeckWorkStatus { compactProgress?: CompactProgress | null; } +export const AUTO_PROCEED_DEFAULT_PROMPT = + '你处于自动推进模式,再次自行审查代码是否符合项目规范、是否已完整满足我的需求、是否已足够简洁清晰、无过度设计或冗余保护。如果你认为已达最终交付状态,请明确答复"无需自动推进"'; + export interface PilotDeckSettings { allowedTools: string[]; disallowedTools: string[]; skipPermissions: boolean; projectSortOrder: string; selfHealContinue?: boolean; + autoProceedOn?: boolean; + autoProceedPrompt?: string; lastUpdated?: string; [key: string]: unknown; } diff --git a/ui/src/components/chat/utils/autoProceedStorage.ts b/ui/src/components/chat/utils/autoProceedStorage.ts new file mode 100644 index 00000000..7b208683 --- /dev/null +++ b/ui/src/components/chat/utils/autoProceedStorage.ts @@ -0,0 +1,45 @@ +/** + * Per-session auto-proceed state, persisted in localStorage. + * Keys: autoProceed_ → '1' | '0' + * + * When no stored value exists, the global autoProceedOn setting serves as + * the default (true for new sessions when the feature is enabled). + */ +const AUTO_PROCEED_PREFIX = 'autoProceed_'; + +import { getPilotDeckSettings } from './chatStorage'; + +export function getAutoProceed(sessionId: string): boolean { + if (!sessionId) return false; + try { + const stored = window.localStorage.getItem(AUTO_PROCEED_PREFIX + sessionId); + if (stored === '0') return false; + if (stored === '1') return true; + // No stored value → use global setting as default + const settings = getPilotDeckSettings(); + return settings.autoProceedOn !== false; + } catch { + // If anything fails, default to on + return true; + } +} + +export function setAutoProceed(sessionId: string, active: boolean): void { + if (!sessionId) return; + try { + if (active) { + window.localStorage.setItem(AUTO_PROCEED_PREFIX + sessionId, '1'); + } else { + window.localStorage.removeItem(AUTO_PROCEED_PREFIX + sessionId); + } + } catch { + // ignore storage errors + } +} + +/** Toggle the current value and return the new state. */ +export function toggleAutoProceed(sessionId: string): boolean { + const next = !getAutoProceed(sessionId); + setAutoProceed(sessionId, next); + return next; +} diff --git a/ui/src/components/chat/utils/chatStorage.ts b/ui/src/components/chat/utils/chatStorage.ts index 3c95882c..8ffb79a4 100644 --- a/ui/src/components/chat/utils/chatStorage.ts +++ b/ui/src/components/chat/utils/chatStorage.ts @@ -1,4 +1,5 @@ import type { PilotDeckSettings } from '../types/types'; +import { AUTO_PROCEED_DEFAULT_PROMPT } from '../types/types'; import { authenticatedFetch } from '../../../utils/api.js'; export const PILOTDECK_SETTINGS_KEY = 'pilotdeck-settings'; @@ -58,6 +59,8 @@ export function getPilotDeckSettings(): PilotDeckSettings { disallowedTools: [], skipPermissions: false, projectSortOrder: 'name', + autoProceedOn: true, + autoProceedPrompt: AUTO_PROCEED_DEFAULT_PROMPT, }; } @@ -76,6 +79,14 @@ export function getPilotDeckSettings(): PilotDeckSettings { typeof parsed.selfHealContinue === 'boolean' ? parsed.selfHealContinue : false, + autoProceedOn: + typeof parsed.autoProceedOn === 'boolean' + ? parsed.autoProceedOn + : true, + autoProceedPrompt: + typeof parsed.autoProceedPrompt === 'string' && parsed.autoProceedPrompt.length > 0 + ? parsed.autoProceedPrompt + : AUTO_PROCEED_DEFAULT_PROMPT, }; } catch { return { @@ -84,6 +95,8 @@ export function getPilotDeckSettings(): PilotDeckSettings { skipPermissions: false, projectSortOrder: 'name', selfHealContinue: false, + autoProceedOn: true, + autoProceedPrompt: AUTO_PROCEED_DEFAULT_PROMPT, }; } } @@ -128,5 +141,9 @@ function mergePermissionSettings(value: unknown): PilotDeckSettings { skipPermissions: Boolean(parsed.skipPermissions), projectSortOrder: current.projectSortOrder || 'name', selfHealContinue: typeof parsed.selfHealContinue === 'boolean' ? parsed.selfHealContinue : current.selfHealContinue, + autoProceedOn: typeof parsed.autoProceedOn === 'boolean' ? parsed.autoProceedOn : current.autoProceedOn, + autoProceedPrompt: typeof parsed.autoProceedPrompt === 'string' && parsed.autoProceedPrompt.length > 0 + ? parsed.autoProceedPrompt + : current.autoProceedPrompt, }; } diff --git a/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx b/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx index 163f7e98..b86666d0 100644 --- a/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx +++ b/ui/src/components/settings/view/tabs/PermissionsSettingsTab.tsx @@ -174,6 +174,8 @@ export default function PermissionsSettingsTab() { const [disallowedTools, setDisallowedTools] = useState([]); const [skipPermissions, setSkipPermissions] = useState(false); const [selfHealContinue, setSelfHealContinue] = useState(false); + const [autoProceedOn, setAutoProceedOn] = useState(true); + const [autoProceedPrompt, setAutoProceedPrompt] = useState(''); const [newAllowed, setNewAllowed] = useState(''); const [newBlocked, setNewBlocked] = useState(''); const [banner, setBanner] = useState(null); @@ -185,6 +187,8 @@ export default function PermissionsSettingsTab() { setDisallowedTools(settings.disallowedTools); setSkipPermissions(settings.skipPermissions); setSelfHealContinue(settings.selfHealContinue || false); + setAutoProceedOn(settings.autoProceedOn !== false); + setAutoProceedPrompt(settings.autoProceedPrompt || ''); }, []); useEffect(() => { @@ -196,6 +200,8 @@ export default function PermissionsSettingsTab() { setDisallowedTools(settings.disallowedTools); setSkipPermissions(settings.skipPermissions); setSelfHealContinue(settings.selfHealContinue || false); + setAutoProceedOn(settings.autoProceedOn !== false); + setAutoProceedPrompt(settings.autoProceedPrompt || ''); }) .catch((error) => { console.error('Failed to load permission settings from backend:', error); @@ -466,6 +472,48 @@ export default function PermissionsSettingsTab() { }} /> + + + {t('permissions.autoProceedOn.title', { defaultValue: 'Auto-proceed (per-session)' })} + + } + description={t('permissions.autoProceedOn.description', { + defaultValue: + 'Default on for new sessions. After each assistant reply the session auto-checks completion and triggers a follow-up prompt unless the reply contains "无需自动推进". Toggle per session via the dot in the sidebar.', + })} + > + { + setAutoProceedOn(value); + persist({ autoProceedOn: value }); + }} + /> + + +