mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e4c74d65 | ||
|
|
78baccedb6 | ||
|
|
84229fd094 | ||
|
|
ee89be883f | ||
|
|
474bbd9d5f | ||
|
|
a562e56175 | ||
|
|
69f3f6daa6 | ||
|
|
3cbf9812d0 | ||
|
|
f579bf4b7c | ||
|
|
7e66fe2ac6 | ||
|
|
683f03bc67 | ||
|
|
4680cacfd2 | ||
|
|
d11225d233 |
@@ -39,6 +39,7 @@ import { BannerCardData } from "@/shared/cline/banner"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { extensionLog } from "@/utils/debugLogger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { PromptRegistry } from "../prompts/system-prompt"
|
||||
import {
|
||||
@@ -325,9 +326,9 @@ export class Controller {
|
||||
})
|
||||
|
||||
if (historyItem) {
|
||||
this.task.resumeTaskFromHistory()
|
||||
await this.task.resumeTaskFromHistory()
|
||||
} else if (task || images || files) {
|
||||
this.task.startTask(task, images, files)
|
||||
await this.task.startTask(task, images, files)
|
||||
}
|
||||
|
||||
return this.task.taskId
|
||||
@@ -409,7 +410,6 @@ export class Controller {
|
||||
async cancelTask() {
|
||||
// Prevent duplicate cancellations from spam clicking
|
||||
if (this.cancelInProgress) {
|
||||
console.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -445,6 +445,11 @@ export class Controller {
|
||||
if (this.task) {
|
||||
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
|
||||
this.task.taskState.abandoned = true
|
||||
|
||||
// NOTE: We DON'T clear messages here anymore.
|
||||
// The 'abandoned' guard in ask() and say() already prevents stale messages from being added.
|
||||
// Clearing messages here caused a bug where new tasks (not yet saved to disk) would lose
|
||||
// their messages on cancel. The in-memory messages are the source of truth until saved.
|
||||
}
|
||||
|
||||
// Small delay to ensure state manager has persisted the history update
|
||||
@@ -455,20 +460,33 @@ export class Controller {
|
||||
try {
|
||||
const result = await this.getTaskWithId(this.task.taskId)
|
||||
historyItem = result.historyItem
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Task not in history yet (new task with no messages); catch the
|
||||
// error to enable the agent to continue making progress.
|
||||
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
|
||||
}
|
||||
|
||||
// Only re-initialize if we found a history item, otherwise just clear
|
||||
// Re-initialize from disk if history exists, otherwise keep current task state
|
||||
if (historyItem) {
|
||||
// Re-initialize task to keep it visible in UI with resume button
|
||||
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
|
||||
} else {
|
||||
await this.clearTask()
|
||||
// Task was saved to disk - reload messages from disk and show resume button
|
||||
// DON'T call initTask() here because it calls clearTask() which wipes messages!
|
||||
try {
|
||||
// Clear cancelInProgress BEFORE resumeTaskFromHistory() because it blocks
|
||||
// waiting for user to click Resume. If we don't clear it here, user can't
|
||||
// cancel again if something goes wrong with parallel tool calls.
|
||||
this.cancelInProgress = false
|
||||
await this.task.resumeTaskFromHistory()
|
||||
// resumeTaskFromHistory() calls ask("resume_task") which posts state to webview
|
||||
// So we don't need to call postStateToWebview() here
|
||||
return
|
||||
} catch (error) {
|
||||
console.error(`[Controller.cancelTask] Failed to resume from history:`, error)
|
||||
// Fall through to keep current task
|
||||
}
|
||||
}
|
||||
|
||||
// If no historyItem OR resume failed, keep the current task instance with its in-memory messages
|
||||
// This handles the case where user cancels before first message is saved
|
||||
|
||||
await this.postStateToWebview()
|
||||
} finally {
|
||||
// Always clear the flag, even if cancellation fails
|
||||
@@ -799,6 +817,12 @@ export class Controller {
|
||||
|
||||
async postStateToWebview() {
|
||||
const state = await this.getStateToPostToWebview()
|
||||
console.log(
|
||||
`[Controller.postStateToWebview] Sending state - taskId: ${this.task?.taskId || "none"}, messages: ${state.clineMessages.length}`,
|
||||
)
|
||||
extensionLog.info(
|
||||
`[postStateToWebview] Sending - taskId: ${this.task?.taskId || "none"}, messageCount: ${state.clineMessages.length}`,
|
||||
)
|
||||
await sendStateUpdate(state)
|
||||
}
|
||||
|
||||
@@ -859,6 +883,9 @@ export class Controller {
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
console.log(
|
||||
`[Controller.getStateToPostToWebview] Got ${clineMessages.length} messages from task ${this.task?.taskId || "none"}`,
|
||||
)
|
||||
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
|
||||
|
||||
const processedTaskHistory = (taskHistory || [])
|
||||
@@ -968,8 +995,20 @@ export class Controller {
|
||||
|
||||
async clearTask() {
|
||||
if (this.task) {
|
||||
// Save current state to disk BEFORE clearing
|
||||
// This ensures messages are persisted if user clicks "New Task" while task is running
|
||||
try {
|
||||
await this.task.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
} catch (error) {
|
||||
console.error(`[Controller.clearTask] Failed to save messages before clearing:`, error)
|
||||
}
|
||||
|
||||
// Clear task settings cache when task ends
|
||||
await this.stateManager.clearTaskSettings()
|
||||
|
||||
// DON'T clear message queues here! We just saved them to disk above.
|
||||
// Clearing them could trigger another auto-save that overwrites with [].
|
||||
// Since we're setting this.task = undefined below anyway, no need to clear.
|
||||
}
|
||||
await this.task?.abortTask()
|
||||
this.task = undefined // removes reference to it, so once promises end it will be garbage collected
|
||||
|
||||
@@ -19,12 +19,15 @@ export async function showTaskWithId(controller: Controller, request: StringRequ
|
||||
|
||||
// We need to initialize the task before returning data
|
||||
if (historyItem) {
|
||||
// Always initialize the task with the history item
|
||||
await controller.initTask(undefined, undefined, undefined, historyItem)
|
||||
|
||||
// Send UI update to show the chat view
|
||||
// Send UI update to show the chat view FIRST
|
||||
// This must happen BEFORE initTask() because initTask() calls resumeTaskFromHistory()
|
||||
// which blocks on ask("resume_task") waiting for user to click Resume.
|
||||
// If we don't navigate to chat view first, user can't see the Resume button!
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
// Now initialize the task with the history item
|
||||
await controller.initTask(undefined, undefined, undefined, historyItem)
|
||||
|
||||
// Return task data for gRPC response
|
||||
return TaskResponse.create({
|
||||
id: historyItem.id,
|
||||
@@ -43,12 +46,12 @@ export async function showTaskWithId(controller: Controller, request: StringRequ
|
||||
// If not in global state, fetch from storage
|
||||
const { historyItem: fetchedItem } = await controller.getTaskWithId(id)
|
||||
|
||||
// Send UI update to show the chat view FIRST (same reason as above)
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
// Initialize the task with the fetched item
|
||||
await controller.initTask(undefined, undefined, undefined, fetchedItem)
|
||||
|
||||
// Send UI update to show the chat view
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
return TaskResponse.create({
|
||||
id: fetchedItem.id,
|
||||
task: fetchedItem.task || "",
|
||||
|
||||
+21
-14
@@ -582,7 +582,7 @@ export class Task {
|
||||
askTs?: number
|
||||
}> {
|
||||
// Allow resume asks even when aborted to enable resume button after cancellation
|
||||
if (this.taskState.abort && type !== "resume_task" && type !== "resume_completed_task") {
|
||||
if ((this.taskState.abort || this.taskState.abandoned) && type !== "resume_task" && type !== "resume_completed_task") {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
let askTs: number
|
||||
@@ -717,7 +717,7 @@ export class Task {
|
||||
partial?: boolean,
|
||||
): Promise<number | undefined> {
|
||||
// Allow hook messages even when aborted to enable proper cleanup
|
||||
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
if ((this.taskState.abort || this.taskState.abandoned) && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
|
||||
@@ -867,9 +867,6 @@ export class Task {
|
||||
|
||||
// Update UI
|
||||
await this.postStateToWebview()
|
||||
|
||||
// Log for debugging/telemetry
|
||||
console.log(`[Task ${this.taskId}] ${hookName} hook cancelled (userInitiated: ${wasCancelled})`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1062,10 +1059,15 @@ export class Task {
|
||||
|
||||
public async resumeTaskFromHistory() {
|
||||
try {
|
||||
await this.clineIgnoreController.initialize()
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
// Optionally, inform the user or handle the error appropriately
|
||||
try {
|
||||
await this.clineIgnoreController.initialize()
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
// Optionally, inform the user or handle the error appropriately
|
||||
}
|
||||
} catch (outerError) {
|
||||
console.error(`[Task.resumeTaskFromHistory] CRITICAL ERROR at start:`, outerError)
|
||||
throw outerError
|
||||
}
|
||||
|
||||
const savedClineMessages = await getSavedClineMessages(this.taskId)
|
||||
@@ -1117,10 +1119,17 @@ export class Task {
|
||||
}
|
||||
|
||||
this.taskState.isInitialized = true
|
||||
this.taskState.abort = false // Reset abort flag when resuming task
|
||||
// NOTE: Do NOT reset abort/abandoned flags here!
|
||||
// The streaming loop needs to see these flags as true to stop.
|
||||
// We reset them AFTER user clicks Resume (after ask() returns).
|
||||
|
||||
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
|
||||
|
||||
// NOW reset the abort/abandoned flags after user clicked Resume or provided input
|
||||
// This ensures the streaming loop has already stopped before we allow new work
|
||||
this.taskState.abort = false
|
||||
this.taskState.abandoned = false
|
||||
|
||||
// Initialize newUserContent array for hook context
|
||||
const newUserContent: ClineContent[] = []
|
||||
|
||||
@@ -1490,9 +1499,8 @@ export class Task {
|
||||
// Present the resume ask - this will show the resume button in the UI
|
||||
// We don't await this because we want to set the abort flag immediately
|
||||
// The ask will be waiting when the user decides to resume
|
||||
this.ask(askType).catch((error) => {
|
||||
// If ask fails (e.g., task was cleared), that's okay - just log it
|
||||
console.log("[TaskCancel] Resume ask failed (task may have been cleared):", error)
|
||||
this.ask(askType).catch(() => {
|
||||
// If ask fails (e.g., task was cleared), that's okay - ignore it
|
||||
})
|
||||
} catch (error) {
|
||||
// TaskCancel hook failed - non-fatal, just log
|
||||
@@ -2466,7 +2474,6 @@ export class Task {
|
||||
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
|
||||
lastMessage.partial = false
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
console.log("updating partial message", lastMessage)
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
// update api_req_started to have cancelled and cost, so that we can display the cost of the partial stream
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { WebviewMessage } from "@/shared/WebviewMessage"
|
||||
import { debugLog } from "@/utils/debugLogger"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -173,6 +174,16 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
}
|
||||
break
|
||||
}
|
||||
case "webview_debug_log": {
|
||||
if (message.webview_debug_log) {
|
||||
const { level, args } = message.webview_debug_log
|
||||
// Map webview log levels to debug logger levels ("log" -> "info")
|
||||
const mappedLevel = level === "log" ? "info" : level
|
||||
// Use unified debug logger - writes to ~/cline-debug.log
|
||||
debugLog("webview", mappedLevel, ...args)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
|
||||
}
|
||||
|
||||
@@ -316,3 +316,18 @@ export interface ClineApiReqInfo {
|
||||
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" | "retries_exhausted"
|
||||
|
||||
export const COMPLETION_RESULT_CHANGES_FLAG = "HAS_CHANGES"
|
||||
|
||||
/**
|
||||
* QueuedMessage
|
||||
* Represents a message that is queued to be sent when sending is enabled
|
||||
*/
|
||||
export interface QueuedMessage {
|
||||
/** Unique identifier for the queued message */
|
||||
id: string
|
||||
/** The text content of the message */
|
||||
text: string
|
||||
/** Array of image data URLs attached to the message */
|
||||
images: string[]
|
||||
/** Array of file paths attached to the message */
|
||||
files: string[]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export interface WebviewMessage {
|
||||
type: "grpc_request" | "grpc_request_cancel"
|
||||
type: "grpc_request" | "grpc_request_cancel" | "webview_debug_log"
|
||||
grpc_request?: GrpcRequest
|
||||
grpc_request_cancel?: GrpcCancel
|
||||
webview_debug_log?: WebviewDebugLog
|
||||
}
|
||||
|
||||
export type WebviewDebugLog = {
|
||||
level: "log" | "warn" | "error" | "debug"
|
||||
args: string[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type GrpcRequest = {
|
||||
|
||||
@@ -622,10 +622,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!sendingDisabled) {
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
// Always call onSend - let handleSendMessage decide whether to queue or send
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" && !isComposing) {
|
||||
@@ -1699,10 +1698,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
)}
|
||||
data-testid="send-button"
|
||||
onClick={() => {
|
||||
if (!sendingDisabled) {
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
// Always call onSend - let handleSendMessage decide whether to queue or send
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { useMount } from "react-use"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ActionButtons,
|
||||
CHAT_CONSTANTS,
|
||||
ChatLayout,
|
||||
canProcessQueue,
|
||||
convertHtmlToMarkdown,
|
||||
filterVisibleMessages,
|
||||
groupLowStakesTools,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
useScrollBehavior,
|
||||
WelcomeSection,
|
||||
} from "./chat-view"
|
||||
import QueuedMessages from "./QueuedMessages"
|
||||
|
||||
interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
@@ -103,8 +105,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
expandedRows,
|
||||
setExpandedRows,
|
||||
textAreaRef,
|
||||
messageQueue,
|
||||
setMessageQueue,
|
||||
clineAsk,
|
||||
} = chatState
|
||||
|
||||
// Processing lock to prevent race conditions in queue processing
|
||||
const isProcessingQueueRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = async (e: ClipboardEvent) => {
|
||||
const targetElement = e.target as HTMLElement | null
|
||||
@@ -312,6 +320,58 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
}
|
||||
}, [isHidden, sendingDisabled, enableButtons])
|
||||
|
||||
// Queue processing effect - processes queued messages when sending becomes enabled
|
||||
// See queueUtils.ts for detailed documentation on blocking logic
|
||||
useEffect(() => {
|
||||
if (
|
||||
!canProcessQueue({
|
||||
sendingDisabled,
|
||||
messageQueueLength: messageQueue.length,
|
||||
clineAsk,
|
||||
isProcessing: isProcessingQueueRef.current,
|
||||
messagesLength: messages.length,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set processing lock immediately to prevent race conditions
|
||||
isProcessingQueueRef.current = true
|
||||
|
||||
// Get the first message but don't remove yet - only remove on successful send
|
||||
const nextMessage = messageQueue[0]
|
||||
|
||||
// Process the message asynchronously
|
||||
const processMessage = async () => {
|
||||
try {
|
||||
const success = await messageHandlers.handleSendMessage(
|
||||
nextMessage.text,
|
||||
nextMessage.images,
|
||||
nextMessage.files,
|
||||
true,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
// Only remove from queue if message was actually sent
|
||||
setMessageQueue((current) => current.slice(1))
|
||||
}
|
||||
// If !success, message stays in queue and will be retried
|
||||
// when state changes trigger the effect again
|
||||
} catch (error) {
|
||||
console.error("[ChatView] Failed to send queued message:", {
|
||||
messageId: nextMessage.id,
|
||||
error,
|
||||
})
|
||||
// On error, message stays in queue (no action needed)
|
||||
} finally {
|
||||
// Release the processing lock
|
||||
isProcessingQueueRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
processMessage()
|
||||
}, [sendingDisabled, messageQueue, clineAsk, setMessageQueue, messageHandlers, messages.length])
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
return filterVisibleMessages(modifiedMessages)
|
||||
}, [modifiedMessages])
|
||||
@@ -379,6 +439,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
</div>
|
||||
<footer className="bg-(--vscode-sidebar-background)" style={{ gridRow: "2" }}>
|
||||
<AutoApproveBar />
|
||||
<QueuedMessages
|
||||
onClearAll={() => chatState.setMessageQueue([])}
|
||||
onRemove={(index) => chatState.setMessageQueue((prev) => prev.filter((_, i) => i !== index))}
|
||||
onUpdate={(index, newText) => {
|
||||
chatState.setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg)))
|
||||
}}
|
||||
queue={chatState.messageQueue}
|
||||
/>
|
||||
<ActionButtons
|
||||
chatState={chatState}
|
||||
messageHandlers={messageHandlers}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { QueuedMessage } from "@shared/ExtensionMessage"
|
||||
import React, { useState } from "react"
|
||||
|
||||
const MAX_QUEUE_SIZE = 5
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
queue: QueuedMessage[]
|
||||
onRemove: (index: number) => void
|
||||
onUpdate: (index: number, newText: string) => void
|
||||
onClearAll: () => void
|
||||
}
|
||||
|
||||
const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpdate, onClearAll }) => {
|
||||
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
|
||||
|
||||
// console.log("[QueuedMessages] Component rendered:", {
|
||||
// queueLength: queue.length,
|
||||
// queue,
|
||||
// })
|
||||
|
||||
if (queue.length === 0) {
|
||||
// console.log("[QueuedMessages] Queue is empty, returning null")
|
||||
return null
|
||||
}
|
||||
|
||||
const getEditState = (messageId: string, currentText: string) => {
|
||||
return editingStates[messageId] || { isEditing: false, value: currentText }
|
||||
}
|
||||
|
||||
const setEditState = (messageId: string, isEditing: boolean, value?: string) => {
|
||||
setEditingStates((prev) => ({
|
||||
...prev,
|
||||
[messageId]: { isEditing, value: value ?? prev[messageId]?.value ?? "" },
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSaveEdit = (index: number, messageId: string, newValue: string) => {
|
||||
console.log("[QueuedMessages] Saving edit:", {
|
||||
index,
|
||||
messageId,
|
||||
newValue,
|
||||
})
|
||||
onUpdate(index, newValue)
|
||||
setEditState(messageId, false)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number, messageId: string) => {
|
||||
console.log("[QueuedMessages] Removing message:", {
|
||||
index,
|
||||
messageId,
|
||||
})
|
||||
onRemove(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-[15px] py-[10px] pr-[6px]" data-testid="queued-messages">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[var(--vscode-descriptionForeground)] text-sm font-medium">
|
||||
Queued Messages ({queue.length}/{MAX_QUEUE_SIZE})
|
||||
</div>
|
||||
<button
|
||||
aria-label="Clear all queued messages"
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium bg-[var(--vscode-button-secondaryBackground)] text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)] transition-colors"
|
||||
onClick={onClearAll}
|
||||
title="Clear all queued messages">
|
||||
<span className="codicon codicon-clear-all text-sm" />
|
||||
<span>Clear All</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
|
||||
{queue.map((message, index) => {
|
||||
const editState = getEditState(message.id, message.text)
|
||||
return (
|
||||
<div
|
||||
className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded p-2 overflow-hidden flex-shrink-0"
|
||||
key={message.id}>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex-grow min-w-0">
|
||||
{editState.isEditing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
className="w-full bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border border-[var(--vscode-input-border)] rounded px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-[var(--vscode-focusBorder)] font-sans text-[13px]"
|
||||
onBlur={() => handleSaveEdit(index, message.id, editState.value)}
|
||||
onChange={(e) => setEditState(message.id, true, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSaveEdit(index, message.id, editState.value)
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditState(message.id, false, message.text)
|
||||
}
|
||||
}}
|
||||
placeholder="Edit message..."
|
||||
ref={(textarea) => {
|
||||
if (textarea) {
|
||||
// Set cursor at the end
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
}
|
||||
}}
|
||||
rows={Math.min(editState.value.split("\n").length, 10)}
|
||||
value={editState.value}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-[var(--vscode-list-hoverBackground)] px-2 py-1 -mx-2 -my-1 rounded transition-colors whitespace-pre-wrap break-words text-[var(--vscode-editor-foreground)] text-[13px]"
|
||||
onClick={() => setEditState(message.id, true, message.text)}
|
||||
title="Click to edit">
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Remove message"
|
||||
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-icon-foreground)] transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(index, message.id)
|
||||
}}
|
||||
title="Remove from queue">
|
||||
<span className="codicon codicon-trash text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{message.images.map((image, imgIndex) => (
|
||||
<img
|
||||
alt={`Attachment ${imgIndex + 1}`}
|
||||
className="max-w-[100px] max-h-[100px] rounded border border-[var(--vscode-panel-border)] object-cover"
|
||||
key={imgIndex}
|
||||
src={image}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{message.files.map((file, fileIndex) => (
|
||||
<div
|
||||
className="px-2 py-1 bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)] rounded text-xs flex items-center gap-1"
|
||||
key={fileIndex}>
|
||||
<span className="codicon codicon-file text-xs" />
|
||||
<span className="truncate max-w-[150px]">{file.split("/").pop()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QueuedMessages
|
||||
@@ -64,16 +64,20 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
|
||||
const handleActionClick = useCallback(
|
||||
(action: ButtonActionType, text?: string, images?: string[], files?: string[]) => {
|
||||
console.log("[CANCEL_FLOW] [ActionButtons] Button clicked:", action)
|
||||
if (isProcessing) {
|
||||
console.log("[CANCEL_FLOW] [ActionButtons] Already processing, ignoring click")
|
||||
return
|
||||
}
|
||||
setIsProcessing(true)
|
||||
|
||||
// Special handling for cancel action
|
||||
if (action === "cancel") {
|
||||
console.log("[CANCEL_FLOW] [ActionButtons] Cancel action detected, resetting processing state")
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
console.log("[CANCEL_FLOW] [ActionButtons] Calling executeButtonAction:", action)
|
||||
messageHandlers.executeButtonAction(action, text, images, files)
|
||||
},
|
||||
[messageHandlers, isProcessing],
|
||||
@@ -83,6 +87,7 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
console.log("[CANCEL_FLOW] [ActionButtons] Escape key pressed, triggering cancel")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
messageHandlers.executeButtonAction("cancel")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ClineMessage, QueuedMessage } from "@shared/ExtensionMessage"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChatState } from "../types/chatTypes"
|
||||
|
||||
@@ -21,6 +21,9 @@ export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Message queue state
|
||||
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
|
||||
|
||||
// Refs
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -35,12 +38,16 @@ export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
setExpandedRows({})
|
||||
}, [])
|
||||
|
||||
// Track previous task timestamp to detect genuinely new tasks
|
||||
const prevTaskTsRef = useRef<number | undefined>(undefined)
|
||||
|
||||
// Reset state when starting new conversation
|
||||
const resetState = useCallback(() => {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setMessageQueue([])
|
||||
}, [])
|
||||
|
||||
// Handle focus change
|
||||
@@ -48,8 +55,27 @@ export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
setIsTextAreaFocused(isFocused)
|
||||
}, [])
|
||||
|
||||
// Auto-expand last message row when task or messages first changed.
|
||||
// Clear message queue when task changes
|
||||
// This handles: undefined → Task B, Task A → Task B, Task A → undefined
|
||||
// Does NOT clear on: Task A → Task A (mode switch), undefined → undefined (no task)
|
||||
useEffect(() => {
|
||||
const currentTaskTs = task?.ts
|
||||
const prevTaskTs = prevTaskTsRef.current
|
||||
|
||||
// Clear queue when:
|
||||
// 1. A new task starts (undefined → Task B, Task A → Task B)
|
||||
// 2. Task is explicitly cleared via "New Task" button (Task A → undefined)
|
||||
// Don't clear when: undefined → undefined (no task, stays no task)
|
||||
const newTaskStarted = currentTaskTs !== undefined && prevTaskTs !== currentTaskTs
|
||||
const taskCleared = currentTaskTs === undefined && prevTaskTs !== undefined
|
||||
|
||||
if (newTaskStarted || taskCleared) {
|
||||
setMessageQueue([])
|
||||
}
|
||||
|
||||
// Always update ref to track task changes
|
||||
prevTaskTsRef.current = currentTaskTs
|
||||
|
||||
clearExpandedRows()
|
||||
}, [task?.ts, clearExpandedRows])
|
||||
|
||||
@@ -76,6 +102,10 @@ export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
expandedRows,
|
||||
setExpandedRows,
|
||||
|
||||
// Message queue state
|
||||
messageQueue,
|
||||
setMessageQueue,
|
||||
|
||||
// Refs
|
||||
textAreaRef,
|
||||
|
||||
|
||||
@@ -21,14 +21,19 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
setSelectedFiles,
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
sendingDisabled,
|
||||
clineAsk,
|
||||
lastMessage,
|
||||
messageQueue,
|
||||
setMessageQueue,
|
||||
} = chatState
|
||||
|
||||
// Handle sending a message
|
||||
// Returns true if message was successfully sent, false otherwise
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string, images: string[], files: string[]) => {
|
||||
async (text: string, images: string[], files: string[], fromQueue = false): Promise<boolean> => {
|
||||
let messageToSend = text.trim()
|
||||
let messageSent = false
|
||||
const hasContent = messageToSend || images.length > 0 || files.length > 0
|
||||
|
||||
// Prepend the active quote if it exists
|
||||
@@ -39,107 +44,150 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
messageToSend = `${prefix} ${formattedQuote} ${suffix} ${messageToSend}`
|
||||
}
|
||||
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
let messageSent = false
|
||||
if (!hasContent) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
await TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({
|
||||
// If sending is disabled and this is not from the queue, add to queue
|
||||
if (sendingDisabled && !fromQueue) {
|
||||
// Check queue size limit (max 5 messages)
|
||||
if (messageQueue.length >= 5) {
|
||||
// Queue is full, don't add and keep input so user doesn't lose their message
|
||||
return false
|
||||
}
|
||||
// Generate a unique ID using timestamp + random component
|
||||
const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setMessageQueue((prev) => [...prev, { id: messageId, text: messageToSend, images, files }])
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
return false // Message was queued, not sent
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
// Clear input BEFORE the async call to avoid race condition
|
||||
// The newTask() call triggers a state update from the backend that can
|
||||
// cause a React re-render before newTask() returns, which would leave
|
||||
// the input with stale data if we clear it after the await.
|
||||
// BUT: Don't clear if processing from queue - user may have typed new text!
|
||||
if (!fromQueue) {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
|
||||
await TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
return true // New task created successfully
|
||||
}
|
||||
|
||||
if (clineAsk) {
|
||||
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
|
||||
// This ensures Enter key and Resume button work identically
|
||||
if (clineAsk === "resume_task" || clineAsk === "resume_completed_task") {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
} else if (clineAsk) {
|
||||
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
|
||||
// This ensures Enter key and Resume button work identically
|
||||
if (clineAsk === "resume_task" || clineAsk === "resume_completed_task") {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
} else {
|
||||
// All other ask types use messageResponse
|
||||
switch (clineAsk) {
|
||||
case "followup":
|
||||
case "plan_mode_respond":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "use_mcp_server":
|
||||
case "completion_result":
|
||||
case "mistake_limit_reached":
|
||||
case "api_req_failed":
|
||||
case "new_task":
|
||||
case "condense":
|
||||
case "report_bug":
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (messages.length > 0) {
|
||||
// No clineAsk set - check if task is actively running
|
||||
// If so, allow interrupting it with feedback
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isTaskRunning =
|
||||
lastMessage.partial === true || (lastMessage.type === "say" && lastMessage.say === "api_req_started")
|
||||
|
||||
if (isTaskRunning) {
|
||||
// Task is running - send message as interruption/feedback
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
} else {
|
||||
// All other ask types use messageResponse
|
||||
switch (clineAsk) {
|
||||
case "followup":
|
||||
case "plan_mode_respond":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "use_mcp_server":
|
||||
case "completion_result":
|
||||
case "mistake_limit_reached":
|
||||
case "api_req_failed":
|
||||
case "new_task":
|
||||
case "condense":
|
||||
case "report_bug":
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (messages.length > 0) {
|
||||
// No clineAsk set - check if task is actively running
|
||||
// If so, allow interrupting it with feedback
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isTaskRunning =
|
||||
lastMessage.partial === true || (lastMessage.type === "say" && lastMessage.say === "api_req_started")
|
||||
|
||||
// Only clear input and disable UI if message was actually sent
|
||||
if (messageSent) {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
if (isTaskRunning) {
|
||||
// Task is running - send message as interruption/feedback
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
messageSent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear input and disable UI if message was actually sent
|
||||
if (messageSent) {
|
||||
// Don't clear input if processing from queue - user may have typed new text!
|
||||
if (!fromQueue) {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
return messageSent
|
||||
},
|
||||
[
|
||||
messages.length,
|
||||
clineAsk,
|
||||
activeQuote,
|
||||
sendingDisabled,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
setSendingDisabled,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
setEnableButtons,
|
||||
setMessageQueue,
|
||||
chatState,
|
||||
],
|
||||
)
|
||||
@@ -147,8 +195,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
// Start a new task
|
||||
const startNewTask = useCallback(async () => {
|
||||
setActiveQuote(null)
|
||||
setMessageQueue([])
|
||||
await TaskServiceClient.clearTask(EmptyRequest.create({}))
|
||||
}, [setActiveQuote])
|
||||
}, [setActiveQuote, setMessageQueue])
|
||||
|
||||
// Clear input state helper
|
||||
const clearInputState = useCallback(() => {
|
||||
@@ -249,14 +298,18 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
break
|
||||
|
||||
case "cancel":
|
||||
// Clear state IMMEDIATELY before async call, not after
|
||||
// The cancelTask() call blocks until user clicks Resume, so we must
|
||||
// clear the queue before calling it, otherwise the queue stays visible
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setMessageQueue([]) // Clear queue on cancel
|
||||
|
||||
if (backgroundCommandRunning) {
|
||||
await TaskServiceClient.cancelBackgroundCommand(EmptyRequest.create({}))
|
||||
} else {
|
||||
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
|
||||
}
|
||||
// Clear any pending state that might interfere with resume
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
break
|
||||
|
||||
case "utility":
|
||||
@@ -290,6 +343,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
backgroundCommandRunning,
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
setMessageQueue,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -15,4 +15,5 @@ export * from "./types/chatTypes"
|
||||
// Export utilities
|
||||
export * from "./utils/markdownUtils"
|
||||
export * from "./utils/messageUtils"
|
||||
export * from "./utils/queueUtils"
|
||||
export * from "./utils/scrollUtils"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared types and interfaces for the chat view components
|
||||
*/
|
||||
|
||||
import { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ClineAsk, ClineMessage, QueuedMessage } from "@shared/ExtensionMessage"
|
||||
import { ListRange, VirtuosoHandle } from "react-virtuoso"
|
||||
import { ButtonActionType } from "../shared/buttonConfig"
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface ChatState {
|
||||
expandedRows: Record<number, boolean>
|
||||
setExpandedRows: React.Dispatch<React.SetStateAction<Record<number, boolean>>>
|
||||
|
||||
// Message queue state
|
||||
messageQueue: QueuedMessage[]
|
||||
setMessageQueue: React.Dispatch<React.SetStateAction<QueuedMessage[]>>
|
||||
|
||||
// Refs
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>
|
||||
|
||||
@@ -67,7 +71,7 @@ export interface ChatState {
|
||||
*/
|
||||
export interface MessageHandlers {
|
||||
executeButtonAction: (action: ButtonActionType, text?: string, images?: string[], files?: string[]) => Promise<void>
|
||||
handleSendMessage: (text: string, images: string[], files: string[]) => Promise<void>
|
||||
handleSendMessage: (text: string, images: string[], files: string[], fromQueue?: boolean) => Promise<boolean>
|
||||
handleTaskCloseButtonClick: () => void
|
||||
startNewTask: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* ==================================================================================
|
||||
* MESSAGE QUEUE BLOCKING LOGIC
|
||||
* ==================================================================================
|
||||
*
|
||||
* This module determines when the message queue should be blocked from processing.
|
||||
* The queue holds messages the user typed while Cline was busy. These messages
|
||||
* should only be sent at appropriate times.
|
||||
*
|
||||
* GENERAL PRINCIPLE:
|
||||
* - Block queue when user needs to make a decision
|
||||
* - Block queue during active operations that could be disrupted
|
||||
* - Allow queue when task is complete or paused for user input
|
||||
*
|
||||
* ==================================================================================
|
||||
* ASK TYPES AND THEIR BLOCKING BEHAVIOR
|
||||
* ==================================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* CATEGORY 1: ALWAYS BLOCKING ASKS
|
||||
*
|
||||
* These asks ALWAYS require user attention and should ALWAYS block the queue,
|
||||
* regardless of auto-approval settings.
|
||||
*
|
||||
* | Ask Type | When It Appears | Why Block Queue |
|
||||
* |-----------------------|---------------------------|------------------------------------|
|
||||
* | api_req_failed | API call failed | User must retry or abort |
|
||||
* | command_output | Command running in term | Wait for command to complete |
|
||||
* | mistake_limit_reached | Too many consecutive errs | User must acknowledge + guide |
|
||||
* | condense | Context window full | User reviewing conversation summary|
|
||||
* | report_bug | User filing bug report | User composing bug details |
|
||||
*/
|
||||
const ALWAYS_BLOCKING_ASKS: readonly ClineAsk[] = [
|
||||
"api_req_failed",
|
||||
"command_output",
|
||||
"mistake_limit_reached",
|
||||
"condense",
|
||||
"report_bug",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* CATEGORY 2: TOOL APPROVAL ASKS (Block Only When Auto-Approve is OFF)
|
||||
*
|
||||
* These asks ONLY appear when the corresponding auto-approval setting is OFF.
|
||||
* When auto-approval is ON, the tool executes immediately and NO ask is shown.
|
||||
*
|
||||
* KEY INSIGHT: We don't need to check autoApprovalSettings here!
|
||||
* If we see one of these asks, it means auto-approval is already OFF.
|
||||
* If auto-approval were ON, the ask would never be created.
|
||||
*
|
||||
* | Ask Type | Auto-Approve Setting | When Ask Appears | When Ask Hidden |
|
||||
* |----------------------|----------------------------|-----------------------------|---------------------------|
|
||||
* | tool | readFiles / editFiles | Setting is OFF | Setting is ON (auto-exec) |
|
||||
* | command | executeSafe/AllCommands | Setting is OFF | Setting is ON (auto-exec) |
|
||||
* | browser_action_launch| useBrowser | Setting is OFF | Setting is ON (auto-exec) |
|
||||
* | use_mcp_server | useMcp | Setting is OFF | Setting is ON (auto-exec) |
|
||||
*
|
||||
* FLOW WHEN AUTO-APPROVE IS ON:
|
||||
* ```
|
||||
* Model calls tool → Backend checks settings → Auto-approve ON →
|
||||
* Execute immediately → No ask() called → clineAsk stays undefined →
|
||||
* Queue sees clineAsk=undefined → NOT blocked → Works correctly ✓
|
||||
* ```
|
||||
*
|
||||
* FLOW WHEN AUTO-APPROVE IS OFF:
|
||||
* ```
|
||||
* Model calls tool → Backend checks settings → Auto-approve OFF →
|
||||
* Call ask("tool", ...) → User sees approval dialog → clineAsk="tool" →
|
||||
* Queue sees clineAsk="tool" → BLOCKED (this is what we want) ✓
|
||||
* ```
|
||||
*/
|
||||
const TOOL_APPROVAL_ASKS: readonly ClineAsk[] = [
|
||||
"tool", // File read/write - blocked when readFiles/editFiles is OFF
|
||||
"command", // Command execution - blocked when executeSafe/AllCommands is OFF
|
||||
"browser_action_launch", // Browser automation - blocked when useBrowser is OFF
|
||||
"use_mcp_server", // MCP tool usage - blocked when useMcp is OFF
|
||||
] as const
|
||||
|
||||
/**
|
||||
* CATEGORY 3: ALWAYS ALLOWING ASKS
|
||||
*
|
||||
* These asks represent natural pause points where the queue SHOULD process.
|
||||
* These are moments when the user would naturally want their queued message sent.
|
||||
*
|
||||
* | Ask Type | When It Appears | Why ALLOW Queue |
|
||||
* |-----------------------|---------------------------|------------------------------------|
|
||||
* | followup | Model asked a question | User can answer + queue fires |
|
||||
* | plan_mode_respond | Plan mode discussion | Conversational, queue is fine |
|
||||
* | completion_result | Task finished | Perfect time for next request |
|
||||
* | resume_task | Resuming paused task | Starting fresh, queue can fire |
|
||||
* | resume_completed_task | Resuming completed task | Starting fresh, queue can fire |
|
||||
* | new_task | New task context created | Starting fresh, queue can fire |
|
||||
*
|
||||
* Note: This array is for documentation purposes. The function below uses
|
||||
* the blocking arrays and allows everything else by default.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const NEVER_BLOCKING_ASKS: readonly ClineAsk[] = [
|
||||
"followup",
|
||||
"plan_mode_respond",
|
||||
"completion_result",
|
||||
"resume_task",
|
||||
"resume_completed_task",
|
||||
"new_task",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Determines if the given ask type should block queue processing.
|
||||
*
|
||||
* @param clineAsk - The current ask type, or undefined if no ask is active
|
||||
* @returns true if queue should be BLOCKED, false if queue can process
|
||||
*
|
||||
* DECISION TREE:
|
||||
* ```
|
||||
* clineAsk === undefined?
|
||||
* └── YES → return false (queue can process, no ask blocking it)
|
||||
* └── NO → Is it in ALWAYS_BLOCKING_ASKS?
|
||||
* └── YES → return true (always block)
|
||||
* └── NO → Is it in TOOL_APPROVAL_ASKS?
|
||||
* └── YES → return true (tool needs approval)
|
||||
* └── NO → return false (allow queue)
|
||||
* ```
|
||||
*
|
||||
* EXAMPLES:
|
||||
*
|
||||
* Case 1: No active ask
|
||||
* ```
|
||||
* shouldBlockQueueForAsk(undefined) → false
|
||||
* // Queue can process - nothing is asking user for input
|
||||
* ```
|
||||
*
|
||||
* Case 2: API failed
|
||||
* ```
|
||||
* shouldBlockQueueForAsk("api_req_failed") → true
|
||||
* // Queue blocked - user must handle error first
|
||||
* ```
|
||||
*
|
||||
* Case 3: Tool approval (auto-approve OFF)
|
||||
* ```
|
||||
* shouldBlockQueueForAsk("tool") → true
|
||||
* // Queue blocked - user must approve/reject tool first
|
||||
* // Note: We only see "tool" ask because auto-approve is OFF
|
||||
* ```
|
||||
*
|
||||
* Case 4: Tool execution (auto-approve ON)
|
||||
* ```
|
||||
* shouldBlockQueueForAsk(undefined) → false
|
||||
* // Queue can process - but sendingDisabled=true blocks it anyway
|
||||
* // Note: clineAsk is undefined because tool auto-executed (no ask shown)
|
||||
* ```
|
||||
*
|
||||
* Case 5: Task completed
|
||||
* ```
|
||||
* shouldBlockQueueForAsk("completion_result") → false
|
||||
* // Queue can process - perfect time to send user's queued message
|
||||
* ```
|
||||
*
|
||||
* Case 6: Model asked followup
|
||||
* ```
|
||||
* shouldBlockQueueForAsk("followup") → false
|
||||
* // Queue can process - user's queued message is their response
|
||||
* ```
|
||||
*/
|
||||
export function shouldBlockQueueForAsk(clineAsk: ClineAsk | undefined): boolean {
|
||||
// No ask active - nothing blocking the queue from the ask side
|
||||
if (!clineAsk) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Category 1: Always block these - user must handle them first
|
||||
if ((ALWAYS_BLOCKING_ASKS as readonly string[]).includes(clineAsk)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Category 2: Block tool approvals - user must approve/reject first
|
||||
// Note: If we see these asks, it means auto-approve is OFF for that tool type.
|
||||
// If auto-approve were ON, the ask would never be created and clineAsk would be undefined.
|
||||
if ((TOOL_APPROVAL_ASKS as readonly string[]).includes(clineAsk)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Category 3 (implicit): Everything else allows queue processing
|
||||
// This includes followup, completion_result, resume_task, etc.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete queue processing eligibility check.
|
||||
* Combines all blocking conditions into a single function.
|
||||
*
|
||||
* @returns true if queue CAN process, false if queue is BLOCKED
|
||||
*
|
||||
* BLOCKING CONDITIONS (if ANY are true, queue is blocked):
|
||||
*
|
||||
* 1. sendingDisabled = true
|
||||
* - Active work happening (streaming, API request, tool executing)
|
||||
* - This is the PRIMARY blocker during normal operation
|
||||
*
|
||||
* 2. messageQueueLength = 0
|
||||
* - No messages to process (obvious)
|
||||
*
|
||||
* 3. isProcessing = true
|
||||
* - Already processing a queued message (prevents race conditions)
|
||||
*
|
||||
* 4. messagesLength = 0
|
||||
* - No task exists (user clicked "New Task", queue should clear not fire)
|
||||
*
|
||||
* 5. shouldBlockQueueForAsk(clineAsk) = true
|
||||
* - User needs to make a decision (see function above for details)
|
||||
*
|
||||
* TIMING DIAGRAM - WHEN EACH BLOCKER IS ACTIVE:
|
||||
*
|
||||
* ```
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* State: | Idle | Stream | Tool(ON) | Tool(OFF) | Error | Complete |
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* sendingDis. | ✗ | ✓ | ✓ | ✗ | ✓* | ✗ |
|
||||
* clineAsk | - | - | - | "tool" | "err" | "complete"|
|
||||
* shouldBlock() | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Queue blocked | ✗ | ✓ | ✓ | ✓ | ✓ | ✗ |
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Legend:
|
||||
* - Tool(ON) = Auto-approve is ON, tool executes immediately
|
||||
* - Tool(OFF) = Auto-approve is OFF, user sees approval dialog
|
||||
* - Error = api_req_failed or similar error state
|
||||
* - * = sendingDisabled may be true or false depending on error type
|
||||
* ```
|
||||
*/
|
||||
export function canProcessQueue(params: {
|
||||
sendingDisabled: boolean
|
||||
messageQueueLength: number
|
||||
clineAsk: ClineAsk | undefined
|
||||
isProcessing: boolean
|
||||
messagesLength: number
|
||||
}): boolean {
|
||||
const { sendingDisabled, messageQueueLength, clineAsk, isProcessing, messagesLength } = params
|
||||
|
||||
// Blocker 1: Active work happening
|
||||
if (sendingDisabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Blocker 2: No messages to send
|
||||
if (messageQueueLength === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Blocker 3: Already processing (prevents duplicate sends)
|
||||
if (isProcessing) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Blocker 4: No task (shouldn't auto-start with queue after "New Task")
|
||||
if (messagesLength === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Blocker 5: Ask requires user decision
|
||||
if (shouldBlockQueueForAsk(clineAsk)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// All checks passed - queue can process!
|
||||
return true
|
||||
}
|
||||
@@ -346,15 +346,46 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
try {
|
||||
const stateData = JSON.parse(response.stateJson) as ExtensionState
|
||||
setState((prevState) => {
|
||||
const incomingTaskId = stateData.currentTaskItem?.id
|
||||
const prevTaskId = prevState.currentTaskItem?.id
|
||||
|
||||
// Versioning logic for autoApprovalSettings
|
||||
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
// HACK: Preserve clineMessages if currentTaskItem is the same
|
||||
if (stateData.currentTaskItem?.id === prevState.currentTaskItem?.id) {
|
||||
stateData.clineMessages = stateData.clineMessages?.length
|
||||
? stateData.clineMessages
|
||||
: prevState.clineMessages
|
||||
// HACK: Preserve clineMessages to avoid losing conversation history
|
||||
// Cases where we preserve prev messages:
|
||||
// 1. Same task ID (both defined) and prev has more messages
|
||||
// 2. Incoming task is undefined but prev task exists (transient cancel state)
|
||||
// 3. Different task ID but incoming has 0 messages (transient state)
|
||||
const incomingCount = stateData.clineMessages?.length ?? 0
|
||||
const prevCount = prevState.clineMessages?.length ?? 0
|
||||
// Reuse incomingTaskId and prevTaskId from debug section above
|
||||
const sameTaskId = incomingTaskId === prevTaskId
|
||||
const bothTasksUndefined = incomingTaskId === undefined && prevTaskId === undefined
|
||||
const incomingTaskUndefined = incomingTaskId === undefined
|
||||
const prevTaskExists = prevTaskId !== undefined
|
||||
|
||||
// Evaluate each condition separately for detailed logging
|
||||
// Condition 1: Same task, prev has more messages, but incoming > 0 (stale update during streaming)
|
||||
const condition1 = sameTaskId && !bothTasksUndefined && prevCount > incomingCount && incomingCount > 0
|
||||
// Condition 2 REMOVED: Was causing "double click" bug on New Task button.
|
||||
// const condition2 = incomingTaskUndefined && prevTaskExists && prevCount > 0
|
||||
// Condition 3: Different task ID (both defined), incoming has 0 messages (loading new task)
|
||||
// NOTE: Only trigger if BOTH task IDs are defined - if incoming is undefined, it's an intentional clear
|
||||
const condition3 =
|
||||
!sameTaskId &&
|
||||
incomingTaskId !== undefined &&
|
||||
prevTaskId !== undefined &&
|
||||
incomingCount === 0 &&
|
||||
prevCount > 0
|
||||
const shouldPreservePrev = condition1 || condition3
|
||||
|
||||
if (shouldPreservePrev) {
|
||||
stateData.clineMessages = prevState.clineMessages
|
||||
// NOTE: We do NOT preserve currentTaskItem here to allow cancel to work
|
||||
// When user cancels, backend sends currentTaskItem: undefined intentionally
|
||||
// Preserving it would make cancel appear stuck
|
||||
}
|
||||
|
||||
const newState = {
|
||||
@@ -414,8 +445,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
{},
|
||||
{
|
||||
onResponse: () => {
|
||||
// When history button is clicked, navigate to history view
|
||||
console.log("[DEBUG] Received history button clicked event from gRPC stream")
|
||||
// When history button is clicked, navigate to history view
|
||||
navigateToHistory()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -432,8 +463,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
{},
|
||||
{
|
||||
onResponse: () => {
|
||||
// When chat button is clicked, navigate to chat
|
||||
console.log("[DEBUG] Received chat button clicked event from gRPC stream")
|
||||
// When chat button is clicked, navigate to chat
|
||||
navigateToChat()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -580,8 +611,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
// Set up account button clicked subscription
|
||||
accountButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToAccountButtonClicked(EmptyRequest.create(), {
|
||||
onResponse: () => {
|
||||
// When account button is clicked, navigate to account view
|
||||
console.log("[DEBUG] Received account button clicked event from gRPC stream")
|
||||
// When account button is clicked, navigate to account view
|
||||
navigateToAccount()
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
Reference in New Issue
Block a user