diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 53ca078a52..e7a5958a57 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -41,11 +41,22 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo // It could be useful for cline to know if the user went from one or no // file to another between messages, so we always include this context. - const visibleFilePaths = vscode.window.visibleTextEditors - ?.map((editor) => editor.document?.uri?.fsPath) + const visibleTabFilePaths = (vscode.window.visibleTextEditors || []) + .map((editor) => editor.document?.uri) .filter(Boolean) - .map((absolutePath) => path.relative(cline.cwd, absolutePath)) - .slice(0, maxWorkspaceFiles) + + const existingVisibleFilePaths = await Promise.all( + visibleTabFilePaths.map(async (uri) => { + try { + await fs.stat(uri.fsPath) + const absolutePath = uri.fsPath + return path.relative(cline.cwd, absolutePath).toPosix() + } catch (error) { + return null + } + }), + ) + const visibleFilePaths = existingVisibleFilePaths.filter((path) => path !== null).slice(0, maxWorkspaceFiles) // Filter paths through rooIgnoreController const allowedVisibleFiles = cline.rooIgnoreController @@ -61,7 +72,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo const maxTabs = maxOpenTabsContext ?? 20 // 获取所有文本编辑器标签的文件路径 - const tabUris = vscode.window.tabGroups.all + const tabUris = (vscode.window.tabGroups?.all || []) .flatMap((group) => group.tabs) .filter((tab) => tab.input instanceof vscode.TabInputText) .map((tab) => (tab.input as vscode.TabInputText).uri) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c1cbfe85ce..3a71ccbb43 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -180,9 +180,6 @@ export class Task extends EventEmitter implements TaskLike { todoList?: TodoItem[] - private readonly timeoutMap = new Map() - private nextTimeoutId = 0 - readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined readonly taskNumber: number @@ -295,6 +292,7 @@ export class Task extends EventEmitter implements TaskLike { private askResponseText?: string private askResponseImages?: string[] public lastMessageTs?: number + private autoApprovalTimeoutRef?: NodeJS.Timeout // Tool Use consecutiveMistakeCount: number = 0 @@ -1104,6 +1102,8 @@ export class Task extends EventEmitter implements TaskLike { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } + let timeouts: NodeJS.Timeout[] = [] + // Automatically approve if the ask according to the user's settings. const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined @@ -1114,22 +1114,13 @@ export class Task extends EventEmitter implements TaskLike { } else if (approval.decision === "deny") { this.denyAsk() } else if (approval.decision === "timeout") { - const timeoutId = this.nextTimeoutId++ - - const timer = setTimeout(() => { + // Store the auto-approval timeout so it can be cancelled if user interacts + this.autoApprovalTimeoutRef = setTimeout(() => { const { askResponse, text, images } = approval.fn() this.handleWebviewAskResponse(askResponse, text, images) - this.timeoutMap.delete(timeoutId) + this.autoApprovalTimeoutRef = undefined }, approval.timeout) - - this.timeoutMap.set(timeoutId, timer) - - if (approval.askType === "followup") { - provider?.postMessageToWebview({ - type: "zgsmFollowupClearTimeout", - value: timeoutId, - }) - } + timeouts.push(this.autoApprovalTimeoutRef) } // The state is mutable if the message is complete and the task will @@ -1146,20 +1137,10 @@ export class Task extends EventEmitter implements TaskLike { if (isStatusMutable) { console.log(`Task#ask: status is mutable -> type: ${type}`) const statusMutationTimeout = 2_000 - const statusMutationHandler = (task: Task, cb: (timeoutId: number) => any, timeout: number) => { - const timeoutId = this.nextTimeoutId++ - const timer = setTimeout(() => { - cb(timeoutId) - task.timeoutMap.delete(timeoutId) - }, timeout) - - task.timeoutMap.set(timeoutId, timer) - } if (isInteractiveAsk(type)) { - statusMutationHandler( - this, - () => { + timeouts.push( + setTimeout(() => { const message = this.findMessageByTimestamp(askTs) if (message) { @@ -1167,34 +1148,29 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.TaskInteractive, this.taskId) provider?.postMessageToWebview({ type: "interactionRequired" }) } - }, - statusMutationTimeout, + }, statusMutationTimeout), ) } else if (isResumableAsk(type)) { - statusMutationHandler( - this, - () => { + timeouts.push( + setTimeout(() => { const message = this.findMessageByTimestamp(askTs) if (message) { this.resumableAsk = message this.emit(RooCodeEventName.TaskResumable, this.taskId) } - }, - statusMutationTimeout, + }, statusMutationTimeout), ) } else if (isIdleAsk(type)) { - statusMutationHandler( - this, - () => { + timeouts.push( + setTimeout(() => { const message = this.findMessageByTimestamp(askTs) if (message) { this.idleAsk = message this.emit(RooCodeEventName.TaskIdle, this.taskId) } - }, - statusMutationTimeout, + }, statusMutationTimeout), ) } } else if (isMessageQueued) { @@ -1243,7 +1219,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseImages = undefined // Cancel the timeouts if they are still running. - this.clearAllAutoApprovalTimeouts() + timeouts.forEach((timeout) => clearTimeout(timeout)) // Switch back to an active state. if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { @@ -1257,23 +1233,6 @@ export class Task extends EventEmitter implements TaskLike { return result } - public clearAutoApprovalTimeout(timeoutId: number): boolean { - const timer = this.timeoutMap.get(timeoutId) - if (timer) { - clearTimeout(timer) - this.timeoutMap.delete(timeoutId) - return true - } - return false - } - - public clearAllAutoApprovalTimeouts(): void { - for (const [timeoutId, timer] of this.timeoutMap) { - clearTimeout(timer) - } - this.timeoutMap.clear() - } - handleWebviewAskResponse( askResponse: ClineAskResponse, text?: string, @@ -1281,6 +1240,9 @@ export class Task extends EventEmitter implements TaskLike { chatType?: "system" | "user", isCommandInput?: boolean, ) { + // Clear any pending auto-approval timeout when user responds + this.cancelAutoApprovalTimeout() + const provider = this.providerRef.deref() if (isCommandInput && provider) { @@ -1351,6 +1313,21 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Cancel any pending auto-approval timeout. + * Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing. + */ + public cancelAutoApprovalTimeout() { + if (this.autoApprovalTimeoutRef) { + clearTimeout(this.autoApprovalTimeoutRef) + this.autoApprovalTimeoutRef = undefined + this.clineMessages + .filter((msg) => msg.type === "ask" && msg.ask === "followup" && !msg.isAnswered) + .forEach((msg) => (msg.isAnswered = true)) + this.saveClineMessages().catch((e) => console.error("Failed to save multiple choice state:", e)) + } + } + public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) { this.handleWebviewAskResponse("yesButtonClicked", text, images) } @@ -2048,7 +2025,9 @@ export class Task extends EventEmitter implements TaskLike { this.emitFinalTokenUsageUpdate() this.emit(RooCodeEventName.TaskAborted) - + this.clineMessages + .filter((msg) => msg.type === "ask" && msg.ask === "followup" && !msg.isAnswered) + .forEach((msg) => (msg.isAnswered = true)) try { this.dispose() // Call the centralized dispose method } catch (error) { @@ -3400,7 +3379,7 @@ export class Task extends EventEmitter implements TaskLike { this.apiConversationHistory.pop() } } - // if (shouldStop) + // Check if we should auto-retry or prompt the user let errorMsg = t("common:errors.unexpected_api_response") // Reuse the state variable from above diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b16492bbba..457be8baee 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1228,6 +1228,11 @@ export const webviewMessageHandler = async ( case "cancelTask": await provider.cancelTask() break + case "cancelAutoApproval": + // Cancel any pending auto-approval timeout for the current task + provider.getCurrentTask()?.cancelAutoApprovalTimeout() + await provider.postStateToWebview() + break case "killBrowserSession": { const task = provider.getCurrentTask() @@ -3259,16 +3264,6 @@ export const webviewMessageHandler = async ( } break } - case "zgsmFollowupClearTimeout": { - const currentTask = provider?.getCurrentTask() - if (currentTask && typeof message.value === "number") { - const cleared = currentTask.clearAutoApprovalTimeout(message.value) - if (!cleared) { - provider?.log(`Failed to clear timeout with ID: ${message.value}`, "info") - } - } - break - } case "zgsmRebuildCodebaseIndex": { try { const { apiConfiguration } = await provider.getState() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7f4e96cbbc..e43aec9748 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -89,7 +89,6 @@ export interface ExtensionMessage { | "zgsmInviteCode" | "zgsmNotices" | "settingsUpdated" - | "zgsmFollowupClearTimeout" // zgsm | "ollamaModels" | "lmStudioModels" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9759dfd65e..f5b2bae848 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -69,6 +69,7 @@ export interface WebviewMessage { | "openFile" | "openMention" | "cancelTask" + | "cancelAutoApproval" | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" @@ -161,7 +162,6 @@ export interface WebviewMessage { | "zgsmProviderTip" | "fetchZgsmInviteCode" | "fixCodebase" - | "zgsmFollowupClearTimeout" | "showTaskWithIdInNewTab" // zgsm | "shareTaskSuccess" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 9f8b7ece0c..97f753c6b2 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -126,6 +126,7 @@ interface ChatRowProps { onFollowUpUnmount?: () => void isFollowUpAnswered?: boolean isMultipleChoiceAnswered?: boolean + isFollowUpAutoApprovalPaused?: boolean editable?: boolean shouldHighlight?: boolean searchResults?: SearchResult[] @@ -195,6 +196,7 @@ export const ChatRowContent = ({ isMultipleChoiceAnswered, // editable, searchQuery, + isFollowUpAutoApprovalPaused, }: ChatRowContentProps) => { const { t, i18n } = useTranslation() @@ -1825,6 +1827,7 @@ export const ChatRowContent = ({ ts={message?.ts} onCancelAutoApproval={onFollowUpUnmount} isAnswered={isFollowUpAnswered} + isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused} /> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 08652bb4f1..a9729a4841 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -154,6 +154,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) + const [userFeedback, setUserFeedback] = useState("") const [selectedImages, setSelectedImages] = useState([]) // We need to hold on to the ask because useEffect > lastMessage will always @@ -187,9 +188,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) - const followUpAutoApproveTimeoutRef = useRef() - // const userRespondedRef = useRef(false) const [currentFollowUpTs, setCurrentFollowUpTs] = useState(-1) const clineAskRef = useRef(clineAsk) @@ -211,6 +209,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + return !!(inputValue && inputValue.trim().length > 0 && clineAsk === "followup") + }, [inputValue, clineAsk]) + + // Cancel auto-approval timeout when user starts typing + useEffect(() => { + // Only send cancel if there's actual input (user is typing) + // and we have a pending follow-up question + if (isFollowUpAutoApprovalPaused) { + vscode.postMessage({ type: "cancelAutoApproval" }) + } + }, [isFollowUpAutoApprovalPaused]) + useEffect(() => { isMountedRef.current = true return () => { @@ -270,7 +282,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -613,7 +620,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Mark that user has responded - // userRespondedRef.current = true const trimmedInput = text?.trim() @@ -774,7 +779,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Mark that user has responded - // userRespondedRef.current = true const trimmedInput = text?.trim() @@ -806,8 +810,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Mark that user has responded if this is a manual click (not auto-approval) - // if (event) { - // userRespondedRef.current = true - // } // Mark the current follow-up question as answered when a suggestion is clicked if (clineAsk === "followup" && !event?.shiftKey) { @@ -1325,18 +1331,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Mark that user has responded - // userRespondedRef.current = true - if (Number.isInteger(followUpAutoApproveTimeoutRef.current)) { - vscode.postMessage({ - type: "zgsmFollowupClearTimeout", - value: followUpAutoApproveTimeoutRef.current, - }) - } - followUpAutoApproveTimeoutRef.current = undefined - }, []) const shouldHighlight = useCallback( (messageOrGroup?: ClineMessage, searchResults: SearchResult[] = [], showSearch?: boolean) => { if (!searchQuery || !showSearch || !messageOrGroup || !searchResults || searchResults.length === 0) { @@ -1395,7 +1389,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction void isAnswered?: boolean + isFollowUpAutoApprovalPaused?: boolean } export const FollowUpSuggest = ({ @@ -24,6 +26,7 @@ export const FollowUpSuggest = ({ ts = 1, onCancelAutoApproval, isAnswered = false, + isFollowUpAutoApprovalPaused = false, }: FollowUpSuggestProps) => { const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState() const [countdown, setCountdown] = useState(null) @@ -33,13 +36,14 @@ export const FollowUpSuggest = ({ // Start countdown timer when auto-approval is enabled for follow-up questions useEffect(() => { // Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected - // Also stop countdown if the question has been answered + // Also stop countdown if the question has been answered or auto-approval is paused (user is typing) if ( autoApprovalEnabled && alwaysAllowFollowupQuestions && suggestions.length > 0 && !suggestionSelected && - !isAnswered + !isAnswered && + !isFollowUpAutoApprovalPaused ) { // Start with the configured timeout in seconds const timeoutMs = @@ -79,6 +83,7 @@ export const FollowUpSuggest = ({ suggestionSelected, onCancelAutoApproval, isAnswered, + isFollowUpAutoApprovalPaused, ]) const handleSuggestionClick = useCallback( (suggestion: SuggestionItem, event: React.MouseEvent) => { @@ -111,18 +116,24 @@ export const FollowUpSuggest = ({
+ {isFirstSuggestion && countdown !== null && !suggestionSelected && !isAnswered && ( +

+ + {t("chat:followUpSuggest.timerPrefix", { seconds: countdown })} +

+ )} {suggestion.mode && (
diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index 7a9d140dd5..c2f2d56f34 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -12,12 +12,13 @@ vi.mock("@src/i18n/TranslationContext", () => ({ if (key === "chat:followUpSuggest.countdownDisplay" && options?.count !== undefined) { return `${options.count}s` } - if (key === "chat:followUpSuggest.autoSelectCountdown" && options?.count !== undefined) { - return `Auto-selecting in ${options.count} seconds` - } if (key === "chat:followUpSuggest.copyToInput") { return "Copy to input" } + if (key === "chat:followUpSuggest.timerPrefix" && options?.seconds !== undefined) { + return "Auto-approve enabled. Selecting in " + options.seconds + "s…" + } + return key }, }), @@ -93,8 +94,9 @@ describe("FollowUpSuggest", () => { defaultTestState, ) - // Should show initial countdown (3 seconds) + // Should countdown and mention expect(screen.getByText(/3s/)).toBeInTheDocument() + expect(screen.getByText(/Selecting in 3s/)).toBeInTheDocument() }) it("should not display countdown timer when isAnswered is true", () => { @@ -410,4 +412,184 @@ describe("FollowUpSuggest", () => { // onSuggestionClick should NOT have been called (component doesn't auto-select) expect(mockOnSuggestionClick).not.toHaveBeenCalled() }) + + describe("isFollowUpAutoApprovalPaused prop", () => { + it("should not display countdown timer when isFollowUpAutoApprovalPaused is true", () => { + renderWithTestProviders( + , + defaultTestState, + ) + + // Should not show countdown when user is typing + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + + it("should stop countdown when user starts typing (isFollowUpAutoApprovalPaused becomes true)", () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Initially should show countdown + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Simulate user starting to type by setting isFollowUpAutoApprovalPaused to true + rerender( + + + + + , + ) + + // Countdown should be hidden immediately when user starts typing + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // Advance timer to ensure countdown doesn't continue + vi.advanceTimersByTime(5000) + + // onSuggestionClick should not have been called + expect(mockOnSuggestionClick).not.toHaveBeenCalled() + + // Countdown should still not be visible + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + + it("should resume countdown when user clears input (isFollowUpAutoApprovalPaused becomes false)", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Should not show countdown when paused + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // Simulate user clearing input by setting isFollowUpAutoApprovalPaused to false + rerender( + + + + + , + ) + + // Countdown should resume from the full timeout + expect(screen.getByText(/3s/)).toBeInTheDocument() + }) + + it("should not show countdown when both isAnswered and isFollowUpAutoApprovalPaused are true", () => { + renderWithTestProviders( + , + defaultTestState, + ) + + // Should not show countdown + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + + it("should handle pause during countdown progress", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Initially should show 3s + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Advance timer by 1 second + await act(async () => { + vi.advanceTimersByTime(1000) + }) + + // Should show 2s + expect(screen.getByText(/2s/)).toBeInTheDocument() + + // User starts typing (pause) + rerender( + + + + + , + ) + + // Countdown should be hidden + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // Advance timer while paused + await act(async () => { + vi.advanceTimersByTime(2000) + }) + + // Countdown should still be hidden + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // User clears input (unpause) - countdown should restart from full duration + rerender( + + + + + , + ) + + // Countdown should restart from full timeout (3s) + expect(screen.getByText(/3s/)).toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index bd6171a16c..e083874e9c 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -56,7 +56,8 @@ }, "reject": { "title": "Reject", - "tooltip": "Reject this action" + "tooltip": "Reject this action", + "askNextStep": "Please immediately terminate the '{{action}}' operation, and wait for me to provide a new plan before proceeding to the next step." }, "completeSubtaskAndReturn": "Complete Subtask and Return", "approve": { @@ -346,8 +347,7 @@ }, "followUpSuggest": { "copyToInput": "Copy to input (same as shift + click)", - "autoSelectCountdown": "Auto-selecting in {{count}}s", - "countdownDisplay": "{{count}}s" + "timerPrefix": "Auto-approve enabled. Selecting in {{seconds}}s…" }, "browser": { "session": "Browser Session", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index a1240e6ceb..f14fdb9012 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -56,7 +56,8 @@ }, "reject": { "title": "拒绝", - "tooltip": "拒绝此操作" + "tooltip": "拒绝此操作", + "askNextStep": "请立即终止 “{{action}}” 操作,并等待我给出新的计划,然后再继续下一步。" }, "completeSubtaskAndReturn": "完成子任务并返回", "approve": { @@ -309,8 +310,7 @@ }, "followUpSuggest": { "copyToInput": "复制到输入框(或按住Shift点击)", - "autoSelectCountdown": "{{count}}秒后自动选择", - "countdownDisplay": "{{count}}秒" + "timerPrefix": "自动批准已启用。{{seconds}}秒后选择中…" }, "announcement": { "title": "Roo Code {{version}} 已发布", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 672ad04f2c..ba7866173f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -56,7 +56,8 @@ }, "reject": { "title": "拒絕", - "tooltip": "拒絕此操作" + "tooltip": "拒絕此操作", + "askNextStep": "請立即終止 “{{action}}” 操作,並等待我有新的計畫,然後再繼續下一步。" }, "completeSubtaskAndReturn": "完成子工作並返回", "approve": { @@ -350,8 +351,7 @@ }, "followUpSuggest": { "copyToInput": "複製到輸入框 (或按住 Shift 並點選)", - "autoSelectCountdown": "{{count}} 秒後自動選擇", - "countdownDisplay": "{{count}} 秒" + "timerPrefix": "自動批准已啟用。{{seconds}}秒後選擇中…" }, "browser": { "session": "瀏覽器會話",