mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
refactor(ChatView): consolidate button state into useReducer
Replace 5 independent useState calls (clineAsk, enableButtons, sendingDisabled, primaryButtonText, secondaryButtonText) with a single useReducer to eliminate partial-update race windows. The race condition: when an api_req_started message clears state via 5 separate setState calls, React can render between updates, leaving clineAsk as undefined while button text still shows 'Start New Task'. Clicks during this window are silently swallowed. With useReducer, all 5 fields update atomically in a single dispatch, making the race structurally impossible. Also preserves the fix for stale isStreaming on secondary button clicks: api_req_failed/mistake_limit_reached/resume_task cases are checked before the isStreaming guard in handleSecondaryButtonClick. 4 action types: - SET_ASK_STATE: atomic update of all 5 fields - SET_SENDING_DISABLED: partial update for retry delay/condensing - SET_BUTTON_TEXT: partial update for subtask resume - RESET_WITHOUT_BUTTON_TEXT: resets without clearing button text Tests: 26/26 pass, tsc clean, lint clean
This commit is contained in:
parent
897c372d2d
commit
f27014377a
2 changed files with 406 additions and 111 deletions
|
|
@ -1,4 +1,13 @@
|
|||
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDeepCompareEffect, useEvent } from "react-use"
|
||||
import debounce from "debounce"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
|
|
@ -62,6 +71,64 @@ export interface ChatViewRef {
|
|||
|
||||
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
|
||||
|
||||
// --- Button state reducer (replaces 5 separate useState calls) ---
|
||||
|
||||
interface ButtonState {
|
||||
sendingDisabled: boolean
|
||||
clineAsk: ClineAsk | undefined
|
||||
enableButtons: boolean
|
||||
primaryButtonText: string | undefined
|
||||
secondaryButtonText: string | undefined
|
||||
}
|
||||
|
||||
type ButtonStateAction =
|
||||
| {
|
||||
type: "SET_ASK_STATE"
|
||||
sendingDisabled: boolean
|
||||
clineAsk: ClineAsk | undefined
|
||||
enableButtons: boolean
|
||||
primaryButtonText: string | undefined
|
||||
secondaryButtonText: string | undefined
|
||||
}
|
||||
| { type: "SET_SENDING_DISABLED"; sendingDisabled: boolean }
|
||||
| { type: "SET_BUTTON_TEXT"; primaryButtonText: string | undefined; secondaryButtonText: string | undefined }
|
||||
| { type: "RESET_WITHOUT_BUTTON_TEXT" }
|
||||
|
||||
const initialButtonState: ButtonState = {
|
||||
sendingDisabled: false,
|
||||
clineAsk: undefined,
|
||||
enableButtons: false,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
}
|
||||
|
||||
function buttonStateReducer(state: ButtonState, action: ButtonStateAction): ButtonState {
|
||||
switch (action.type) {
|
||||
case "SET_ASK_STATE":
|
||||
return {
|
||||
sendingDisabled: action.sendingDisabled,
|
||||
clineAsk: action.clineAsk,
|
||||
enableButtons: action.enableButtons,
|
||||
primaryButtonText: action.primaryButtonText,
|
||||
secondaryButtonText: action.secondaryButtonText,
|
||||
}
|
||||
case "SET_SENDING_DISABLED":
|
||||
return { ...state, sendingDisabled: action.sendingDisabled }
|
||||
case "SET_BUTTON_TEXT":
|
||||
return {
|
||||
...state,
|
||||
primaryButtonText: action.primaryButtonText,
|
||||
secondaryButtonText: action.secondaryButtonText,
|
||||
}
|
||||
case "RESET_WITHOUT_BUTTON_TEXT":
|
||||
return { ...state, sendingDisabled: true, clineAsk: undefined, enableButtons: false }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// --- End button state reducer ---
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||
|
||||
const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
|
||||
|
|
@ -140,17 +207,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const [inputValue, setInputValue] = useState("")
|
||||
const inputValueRef = useRef(inputValue)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [sendingDisabled, setSendingDisabled] = useState(false)
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
|
||||
// Button state managed by a single reducer to eliminate partial-update race conditions.
|
||||
// We need to hold on to the ask because useEffect > lastMessage will always
|
||||
// let us know when an ask comes in and handle it, but by the time
|
||||
// handleMessage is called, the last message might not be the ask anymore
|
||||
// (it could be a say that followed).
|
||||
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [buttonState, dispatch] = useReducer(buttonStateReducer, initialButtonState)
|
||||
const { clineAsk, enableButtons, sendingDisabled, primaryButtonText, secondaryButtonText } = buttonState
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
const [_didClickCancel, setDidClickCancel] = useState(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
|
@ -292,101 +356,127 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
switch (lastMessage.ask) {
|
||||
case "api_req_failed":
|
||||
playSound("progress_loop")
|
||||
setSendingDisabled(true)
|
||||
setClineAsk("api_req_failed")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:retry.title"))
|
||||
setSecondaryButtonText(t("chat:startNewTask.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: true,
|
||||
clineAsk: "api_req_failed",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:retry.title"),
|
||||
secondaryButtonText: t("chat:startNewTask.title"),
|
||||
})
|
||||
break
|
||||
case "mistake_limit_reached":
|
||||
playSound("progress_loop")
|
||||
setSendingDisabled(false)
|
||||
setClineAsk("mistake_limit_reached")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:proceedAnyways.title"))
|
||||
setSecondaryButtonText(t("chat:startNewTask.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: "mistake_limit_reached",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:proceedAnyways.title"),
|
||||
secondaryButtonText: t("chat:startNewTask.title"),
|
||||
})
|
||||
break
|
||||
case "followup":
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("followup")
|
||||
// setting enable buttons to `false` would trigger a focus grab when
|
||||
// the text area is enabled which is undesirable.
|
||||
// We have no buttons for this tool, so no problem having them "enabled"
|
||||
// to workaround this issue. See #1358.
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: isPartial,
|
||||
clineAsk: "followup",
|
||||
enableButtons: true,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
break
|
||||
case "tool":
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("tool")
|
||||
setEnableButtons(!isPartial)
|
||||
case "tool": {
|
||||
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
|
||||
let toolPrimaryText: string | undefined
|
||||
let toolSecondaryText: string | undefined
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
case "appliedDiff":
|
||||
case "newFileCreated":
|
||||
if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
|
||||
setPrimaryButtonText(t("chat:edit-batch.approve.title"))
|
||||
setSecondaryButtonText(t("chat:edit-batch.deny.title"))
|
||||
toolPrimaryText = t("chat:edit-batch.approve.title")
|
||||
toolSecondaryText = t("chat:edit-batch.deny.title")
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:save.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
toolPrimaryText = t("chat:save.title")
|
||||
toolSecondaryText = t("chat:reject.title")
|
||||
}
|
||||
break
|
||||
case "generateImage":
|
||||
setPrimaryButtonText(t("chat:save.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
toolPrimaryText = t("chat:save.title")
|
||||
toolSecondaryText = t("chat:reject.title")
|
||||
break
|
||||
case "finishTask":
|
||||
setPrimaryButtonText(t("chat:completeSubtaskAndReturn"))
|
||||
setSecondaryButtonText(undefined)
|
||||
toolPrimaryText = t("chat:completeSubtaskAndReturn")
|
||||
toolSecondaryText = undefined
|
||||
break
|
||||
case "readFile":
|
||||
if (tool.batchFiles && Array.isArray(tool.batchFiles)) {
|
||||
setPrimaryButtonText(t("chat:read-batch.approve.title"))
|
||||
setSecondaryButtonText(t("chat:read-batch.deny.title"))
|
||||
toolPrimaryText = t("chat:read-batch.approve.title")
|
||||
toolSecondaryText = t("chat:read-batch.deny.title")
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
toolPrimaryText = t("chat:approve.title")
|
||||
toolSecondaryText = t("chat:reject.title")
|
||||
}
|
||||
break
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
if (tool.batchDirs && Array.isArray(tool.batchDirs)) {
|
||||
setPrimaryButtonText(t("chat:list-batch.approve.title"))
|
||||
setSecondaryButtonText(t("chat:list-batch.deny.title"))
|
||||
toolPrimaryText = t("chat:list-batch.approve.title")
|
||||
toolSecondaryText = t("chat:list-batch.deny.title")
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
toolPrimaryText = t("chat:approve.title")
|
||||
toolSecondaryText = t("chat:reject.title")
|
||||
}
|
||||
break
|
||||
default:
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
toolPrimaryText = t("chat:approve.title")
|
||||
toolSecondaryText = t("chat:reject.title")
|
||||
break
|
||||
}
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: isPartial,
|
||||
clineAsk: "tool",
|
||||
enableButtons: !isPartial,
|
||||
primaryButtonText: toolPrimaryText,
|
||||
secondaryButtonText: toolSecondaryText,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "command":
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("command")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText(t("chat:runCommand.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: isPartial,
|
||||
clineAsk: "command",
|
||||
enableButtons: !isPartial,
|
||||
primaryButtonText: t("chat:runCommand.title"),
|
||||
secondaryButtonText: t("chat:reject.title"),
|
||||
})
|
||||
break
|
||||
case "command_output":
|
||||
setSendingDisabled(false)
|
||||
setClineAsk("command_output")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:proceedWhileRunning.title"))
|
||||
setSecondaryButtonText(t("chat:killCommand.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: "command_output",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:proceedWhileRunning.title"),
|
||||
secondaryButtonText: t("chat:killCommand.title"),
|
||||
})
|
||||
break
|
||||
case "use_mcp_server":
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("use_mcp_server")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: isPartial,
|
||||
clineAsk: "use_mcp_server",
|
||||
enableButtons: !isPartial,
|
||||
primaryButtonText: t("chat:approve.title"),
|
||||
secondaryButtonText: t("chat:reject.title"),
|
||||
})
|
||||
break
|
||||
case "completion_result":
|
||||
// Extension waiting for feedback, but we can just present a new task button.
|
||||
|
|
@ -394,16 +484,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (!isPartial && messageQueue.length === 0) {
|
||||
playSound("celebration")
|
||||
}
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("completion_result")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: isPartial,
|
||||
clineAsk: "completion_result",
|
||||
enableButtons: !isPartial,
|
||||
primaryButtonText: t("chat:startNewTask.title"),
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
break
|
||||
case "resume_task":
|
||||
setSendingDisabled(false)
|
||||
setClineAsk("resume_task")
|
||||
setEnableButtons(true)
|
||||
case "resume_task": {
|
||||
// For completed subtasks, show "Start New Task" instead of "Resume"
|
||||
// A subtask is considered completed if:
|
||||
// - It has a parentTaskId AND
|
||||
|
|
@ -414,20 +504,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
|
||||
)
|
||||
if (isCompletedSubtask) {
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: "resume_task",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:startNewTask.title"),
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:resumeTask.title"))
|
||||
setSecondaryButtonText(t("chat:terminate.title"))
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: "resume_task",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:resumeTask.title"),
|
||||
secondaryButtonText: t("chat:terminate.title"),
|
||||
})
|
||||
}
|
||||
setDidClickCancel(false) // special case where we reset the cancel button state
|
||||
break
|
||||
}
|
||||
case "resume_completed_task":
|
||||
setSendingDisabled(false)
|
||||
setClineAsk("resume_completed_task")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: "resume_completed_task",
|
||||
enableButtons: true,
|
||||
primaryButtonText: t("chat:startNewTask.title"),
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
}
|
||||
|
|
@ -438,21 +544,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
switch (lastMessage.say) {
|
||||
case "api_req_retry_delayed":
|
||||
case "api_req_rate_limit_wait":
|
||||
setSendingDisabled(true)
|
||||
dispatch({ type: "SET_SENDING_DISABLED", sendingDisabled: true })
|
||||
break
|
||||
case "api_req_started":
|
||||
// Clear button state when a new API request starts
|
||||
// This fixes buttons persisting when the task continues
|
||||
setSendingDisabled(true)
|
||||
// Note: Do NOT clear selectedImages here. This handler fires
|
||||
// every time the backend starts an API call, which would wipe
|
||||
// images the user has pasted while the chat is in progress.
|
||||
// Images are already cleared in the appropriate user-action
|
||||
// handlers (handleSendMessage, handlePrimaryButtonClick, etc.).
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: true,
|
||||
clineAsk: undefined,
|
||||
enableButtons: false,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
break
|
||||
case "api_req_finished":
|
||||
case "error":
|
||||
|
|
@ -475,19 +584,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
|
||||
)
|
||||
if (hasCompletionResult) {
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_BUTTON_TEXT",
|
||||
primaryButtonText: t("chat:startNewTask.title"),
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [clineAsk, currentTaskItem?.parentTaskId, messages, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
setSendingDisabled(false)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: false,
|
||||
clineAsk: undefined,
|
||||
enableButtons: false,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
|
|
@ -634,13 +749,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
// Only reset message-specific state, preserving mode.
|
||||
setInputValue("")
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
// Do not reset mode here as it should persist.
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
// Intentionally does NOT clear button text.
|
||||
dispatch({ type: "RESET_WITHOUT_BUTTON_TEXT" })
|
||||
}, [])
|
||||
|
||||
/**
|
||||
|
|
@ -842,11 +954,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
break
|
||||
}
|
||||
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: true,
|
||||
clineAsk: undefined,
|
||||
enableButtons: false,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
},
|
||||
[clineAsk, startNewTask, currentTaskItem?.parentTaskId],
|
||||
)
|
||||
|
|
@ -858,6 +973,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const trimmedInput = text?.trim()
|
||||
|
||||
// Handle "Start New Task" cases first — these must take priority over
|
||||
// the isStreaming guard because isStreaming can be stale-true after
|
||||
// api_req_failed or mistake_limit_reached, causing clicks to route
|
||||
// to cancelTask instead of startNewTask.
|
||||
if (clineAsk === "api_req_failed" || clineAsk === "mistake_limit_reached" || clineAsk === "resume_task") {
|
||||
startNewTask()
|
||||
dispatch({
|
||||
type: "SET_ASK_STATE",
|
||||
sendingDisabled: true,
|
||||
clineAsk: undefined,
|
||||
enableButtons: false,
|
||||
primaryButtonText: undefined,
|
||||
secondaryButtonText: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
vscode.postMessage({ type: "cancelTask" })
|
||||
setDidClickCancel(true)
|
||||
|
|
@ -865,11 +997,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "mistake_limit_reached":
|
||||
case "resume_task":
|
||||
startNewTask()
|
||||
break
|
||||
case "command":
|
||||
case "tool":
|
||||
case "use_mcp_server":
|
||||
|
|
@ -893,9 +1020,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
dispatch({ type: "RESET_WITHOUT_BUTTON_TEXT" })
|
||||
},
|
||||
[clineAsk, startNewTask, isStreaming, setDidClickCancel],
|
||||
)
|
||||
|
|
@ -967,7 +1092,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// Same reasoning as above - we trust this is for the current task
|
||||
if (message.text) {
|
||||
if (isCondensing && sendingDisabled) {
|
||||
setSendingDisabled(false)
|
||||
dispatch({ type: "SET_SENDING_DISABLED", sendingDisabled: false })
|
||||
}
|
||||
setIsCondensing(false)
|
||||
}
|
||||
|
|
@ -1568,7 +1693,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
return
|
||||
}
|
||||
setIsCondensing(true)
|
||||
setSendingDisabled(true)
|
||||
dispatch({ type: "SET_SENDING_DISABLED", sendingDisabled: true })
|
||||
vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1208,3 +1208,173 @@ describe("ChatView - Context Condensing Indicator Tests", () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView - Button Click Handler Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(vscode.postMessage).mockClear()
|
||||
})
|
||||
|
||||
it("calls startNewTask (clearTask) when secondary button clicked with api_req_failed and stale isStreaming true", async () => {
|
||||
const { getByText } = renderChatView()
|
||||
|
||||
const baseTsA = Date.now()
|
||||
|
||||
// Step 1: Post messages with api_req_failed as the last message.
|
||||
// useDeepCompareEffect fires and sets clineAsk = "api_req_failed",
|
||||
// enableButtons = true, primaryButtonText = "chat:retry.title",
|
||||
// secondaryButtonText = "chat:startNewTask.title".
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: baseTsA - 4000,
|
||||
text: "Initial task",
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
ts: baseTsA - 3000,
|
||||
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
ts: baseTsA - 2000,
|
||||
text: "API request failed",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the secondary button to appear with "Start New Task" text
|
||||
await waitFor(() => {
|
||||
expect(getByText("chat:startNewTask.title")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Step 2: Append api_req_retry_delayed as the last message.
|
||||
// This makes isLastAsk = false so isToolCurrentlyAsking = false.
|
||||
// Since the last api_req_started has no cost, isStreaming becomes true.
|
||||
// clineAsk remains "api_req_failed" because api_req_retry_delayed only
|
||||
// sets setSendingDisabled(true) without clearing button state.
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: baseTsA - 4000,
|
||||
text: "Initial task",
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
ts: baseTsA - 3000,
|
||||
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost → isStreaming = true
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
ts: baseTsA - 2000,
|
||||
text: "API request failed",
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_retry_delayed",
|
||||
ts: baseTsA - 1000,
|
||||
text: "Retrying in 5s",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Allow React effects to fully propagate
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
// The secondary button should still be visible with "Start New Task"
|
||||
await waitFor(() => {
|
||||
expect(getByText("chat:startNewTask.title")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Clear previous postMessage calls
|
||||
vi.mocked(vscode.postMessage).mockClear()
|
||||
|
||||
// Click the secondary button (shows "chat:startNewTask.title").
|
||||
// The early-return path in handleSecondaryButtonClick for api_req_failed
|
||||
// should call startNewTask() before the isStreaming guard, preventing
|
||||
// cancelTask from being called.
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText("chat:startNewTask.title"))
|
||||
})
|
||||
|
||||
// Verify startNewTask was called (posts clearTask), not cancelTask
|
||||
await waitFor(() => {
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "clearTask" })
|
||||
})
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "cancelTask" })
|
||||
})
|
||||
|
||||
it("calls startNewTask (clearTask) when primary button clicked with Start New Task text on completion_result", async () => {
|
||||
const { getByText } = renderChatView()
|
||||
|
||||
// Set up completion_result ask (non-partial) which sets:
|
||||
// - clineAsk = "completion_result"
|
||||
// - primaryButtonText = t("chat:startNewTask.title") → "chat:startNewTask.title"
|
||||
// - enableButtons = true
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Initial task",
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
ts: Date.now() - 1500,
|
||||
text: JSON.stringify({
|
||||
apiProtocol: "anthropic",
|
||||
cost: 0.05,
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "completion_result",
|
||||
ts: Date.now(),
|
||||
text: "Task completed successfully",
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the primary button with "Start New Task" text to appear
|
||||
await waitFor(() => {
|
||||
expect(getByText("chat:startNewTask.title")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Allow React effects to fully propagate
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
// Clear previous postMessage calls
|
||||
vi.mocked(vscode.postMessage).mockClear()
|
||||
|
||||
// Click the primary button. With clineAsk = "completion_result", the
|
||||
// handler's case "completion_result" calls startNewTask(). The default
|
||||
// case fallback also checks primaryButtonText against
|
||||
// t("chat:startNewTask.title") for safety when clineAsk becomes undefined
|
||||
// due to race conditions.
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText("chat:startNewTask.title"))
|
||||
})
|
||||
|
||||
// Verify startNewTask was called (posts clearTask)
|
||||
await waitFor(() => {
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "clearTask" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue