mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
fix(chat): harden responsiveness and deterministic pending transitions
This commit is contained in:
parent
b8ef352808
commit
0a9c2b910d
6 changed files with 2165 additions and 189 deletions
|
|
@ -52,6 +52,8 @@ import { WorktreeSelector } from "./WorktreeSelector"
|
|||
import DismissibleUpsell from "../common/DismissibleUpsell"
|
||||
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
|
||||
import { Cloud } from "lucide-react"
|
||||
import { emitFollowUpInteractionMarker } from "./followUpInteractionInstrumentation"
|
||||
import { usePendingActionContract } from "./usePendingActionContract"
|
||||
|
||||
export interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
|
|
@ -156,6 +158,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
const clineAskRef = useRef<ClineAsk | undefined>(undefined)
|
||||
const [_didClickCancel, setDidClickCancel] = useState(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
|
@ -171,15 +174,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
>(undefined)
|
||||
const [isCondensing, setIsCondensing] = useState<boolean>(false)
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
|
||||
const {
|
||||
isPending: isApprovalActionPending,
|
||||
tryBeginPendingAction: tryBeginApprovalActionPending,
|
||||
clearPendingAction: clearApprovalActionPending,
|
||||
} = usePendingActionContract()
|
||||
const {
|
||||
isPending: isFollowUpSuggestionActionPending,
|
||||
tryBeginPendingAction: tryBeginFollowUpSuggestionActionPending,
|
||||
clearPendingAction: clearFollowUpSuggestionActionPending,
|
||||
} = usePendingActionContract()
|
||||
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
|
||||
new LRUCache({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 5,
|
||||
}),
|
||||
)
|
||||
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const cacheCleanupSnapshotRef = useRef<{
|
||||
currentMessageIds: Set<number>
|
||||
viewportMessageIds: Set<number>
|
||||
}>({
|
||||
currentMessageIds: new Set<number>(),
|
||||
viewportMessageIds: new Set<number>(),
|
||||
})
|
||||
const userRespondedRef = useRef<boolean>(false)
|
||||
const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
|
||||
const followUpInstrumentationRef = useRef<{ pendingTs: number | null; settledTs: number | null }>({
|
||||
pendingTs: null,
|
||||
settledTs: null,
|
||||
})
|
||||
const [aggregatedCostsMap, setAggregatedCostsMap] = useState<
|
||||
Map<
|
||||
string,
|
||||
|
|
@ -191,11 +214,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
>
|
||||
>(new Map())
|
||||
|
||||
const clineAskRef = useRef(clineAsk)
|
||||
useEffect(() => {
|
||||
clineAskRef.current = clineAsk
|
||||
}, [clineAsk])
|
||||
|
||||
const {
|
||||
isOpen: isUpsellOpen,
|
||||
openUpsell,
|
||||
|
|
@ -210,6 +228,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
inputValueRef.current = inputValue
|
||||
}, [inputValue])
|
||||
|
||||
useEffect(() => {
|
||||
clineAskRef.current = clineAsk
|
||||
}, [clineAsk])
|
||||
|
||||
// Compute whether auto-approval is paused (user is typing in a followup)
|
||||
const isFollowUpAutoApprovalPaused = useMemo(() => {
|
||||
return !!(inputValue && inputValue.trim().length > 0 && clineAsk === "followup")
|
||||
|
|
@ -243,6 +265,44 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const lastMessage = useMemo(() => messages.at(-1), [messages])
|
||||
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
|
||||
|
||||
const getCurrentAskForInteraction = useCallback((): ClineAsk | undefined => {
|
||||
const latestMessage = messagesRef.current.at(-1)
|
||||
|
||||
if (latestMessage?.type === "ask") {
|
||||
return latestMessage.ask
|
||||
}
|
||||
|
||||
return clineAskRef.current
|
||||
}, [])
|
||||
|
||||
const activeFollowUpTs = useMemo<number | null>(() => {
|
||||
const latestMessage = messages.at(-1)
|
||||
|
||||
if (latestMessage?.type !== "ask" || latestMessage.ask !== "followup") {
|
||||
return null
|
||||
}
|
||||
|
||||
return latestMessage.ts
|
||||
}, [messages])
|
||||
|
||||
const isCurrentSubtaskCompleted = useCallback((): boolean => {
|
||||
return Boolean(
|
||||
currentTaskItem?.parentTaskId &&
|
||||
messagesRef.current.some((msg) => msg.ask === "completion_result" || msg.say === "completion_result"),
|
||||
)
|
||||
}, [currentTaskItem?.parentTaskId])
|
||||
|
||||
const applyResumeTaskControlState = useCallback(() => {
|
||||
if (isCurrentSubtaskCompleted()) {
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setPrimaryButtonText(t("chat:resumeTask.title"))
|
||||
setSecondaryButtonText(t("chat:terminate.title"))
|
||||
}, [isCurrentSubtaskCompleted, t])
|
||||
|
||||
const volume = typeof soundVolume === "number" ? soundVolume : 0.5
|
||||
const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled })
|
||||
const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled })
|
||||
|
|
@ -407,22 +467,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setSendingDisabled(false)
|
||||
setClineAsk("resume_task")
|
||||
setEnableButtons(true)
|
||||
// For completed subtasks, show "Start New Task" instead of "Resume"
|
||||
// A subtask is considered completed if:
|
||||
// - It has a parentTaskId AND
|
||||
// - Its messages contain a completion_result (either ask or say)
|
||||
const isCompletedSubtask =
|
||||
currentTaskItem?.parentTaskId &&
|
||||
messages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
|
||||
)
|
||||
if (isCompletedSubtask) {
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:resumeTask.title"))
|
||||
setSecondaryButtonText(t("chat:terminate.title"))
|
||||
}
|
||||
applyResumeTaskControlState()
|
||||
setDidClickCancel(false) // special case where we reset the cancel button state
|
||||
break
|
||||
case "resume_completed_task":
|
||||
|
|
@ -467,20 +512,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
break
|
||||
}
|
||||
}
|
||||
}, [lastMessage, secondLastMessage])
|
||||
}, [lastMessage, secondLastMessage, applyResumeTaskControlState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isApprovalActionPending) {
|
||||
return
|
||||
}
|
||||
|
||||
clearApprovalActionPending()
|
||||
}, [messages, isApprovalActionPending, clearApprovalActionPending])
|
||||
|
||||
// Update button text when messages change (e.g., completion_result is added) for subtasks in resume_task state
|
||||
useEffect(() => {
|
||||
if (clineAsk === "resume_task" && currentTaskItem?.parentTaskId) {
|
||||
const hasCompletionResult = messages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
|
||||
)
|
||||
if (hasCompletionResult) {
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
}
|
||||
if (clineAsk === "resume_task") {
|
||||
applyResumeTaskControlState()
|
||||
}
|
||||
}, [clineAsk, currentTaskItem?.parentTaskId, messages, t])
|
||||
}, [clineAsk, applyResumeTaskControlState])
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
|
|
@ -499,12 +546,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
|
||||
setIsCondensing(false) // Reset condensing state when switching tasks
|
||||
// Note: sendingDisabled is not reset here as it's managed by message effects
|
||||
|
||||
// Clear any pending auto-approval timeout from previous task
|
||||
if (autoApproveTimeoutRef.current) {
|
||||
clearTimeout(autoApproveTimeoutRef.current)
|
||||
autoApproveTimeoutRef.current = null
|
||||
}
|
||||
// Reset user response flag for new task
|
||||
userRespondedRef.current = false
|
||||
}, [task?.ts])
|
||||
|
|
@ -603,16 +644,79 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const markFollowUpAsAnswered = useCallback(() => {
|
||||
const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup")
|
||||
if (lastFollowUpMessage) {
|
||||
if (followUpInstrumentationRef.current.pendingTs !== lastFollowUpMessage.ts) {
|
||||
emitFollowUpInteractionMarker({
|
||||
stage: "pending_render",
|
||||
followUpTs: lastFollowUpMessage.ts,
|
||||
source: "chat_view",
|
||||
})
|
||||
followUpInstrumentationRef.current.pendingTs = lastFollowUpMessage.ts
|
||||
followUpInstrumentationRef.current.settledTs = null
|
||||
}
|
||||
|
||||
setCurrentFollowUpTs(lastFollowUpMessage.ts)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChatReset = useCallback(() => {
|
||||
// Clear any pending auto-approval timeout
|
||||
if (autoApproveTimeoutRef.current) {
|
||||
clearTimeout(autoApproveTimeoutRef.current)
|
||||
autoApproveTimeoutRef.current = null
|
||||
useEffect(() => {
|
||||
const pendingTs = followUpInstrumentationRef.current.pendingTs
|
||||
|
||||
if (pendingTs === null || currentFollowUpTs !== pendingTs) {
|
||||
return
|
||||
}
|
||||
|
||||
if (followUpInstrumentationRef.current.settledTs === pendingTs) {
|
||||
return
|
||||
}
|
||||
|
||||
emitFollowUpInteractionMarker({
|
||||
stage: "settle",
|
||||
followUpTs: pendingTs,
|
||||
source: "chat_view",
|
||||
})
|
||||
followUpInstrumentationRef.current.settledTs = pendingTs
|
||||
}, [currentFollowUpTs])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFollowUpTs !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { pendingTs, settledTs } = followUpInstrumentationRef.current
|
||||
|
||||
if (pendingTs === null && settledTs === null) {
|
||||
return
|
||||
}
|
||||
|
||||
emitFollowUpInteractionMarker({
|
||||
stage: "clear",
|
||||
followUpTs: pendingTs,
|
||||
source: "chat_view",
|
||||
})
|
||||
|
||||
followUpInstrumentationRef.current.pendingTs = null
|
||||
followUpInstrumentationRef.current.settledTs = null
|
||||
}, [currentFollowUpTs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFollowUpSuggestionActionPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasFollowUpAsk = messages.some((message) => message.type === "ask" && message.ask === "followup")
|
||||
|
||||
if (currentFollowUpTs !== null || !hasFollowUpAsk || getCurrentAskForInteraction() !== "followup") {
|
||||
clearFollowUpSuggestionActionPending()
|
||||
}
|
||||
}, [
|
||||
messages,
|
||||
currentFollowUpTs,
|
||||
isFollowUpSuggestionActionPending,
|
||||
clearFollowUpSuggestionActionPending,
|
||||
getCurrentAskForInteraction,
|
||||
])
|
||||
|
||||
const handleChatReset = useCallback(() => {
|
||||
// Reset user response flag for new message
|
||||
userRespondedRef.current = false
|
||||
|
||||
|
|
@ -633,8 +737,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
* @param images - Array of image data URLs to send with the message
|
||||
*/
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string, images: string[]) => {
|
||||
(
|
||||
text: string,
|
||||
images: string[],
|
||||
options?: {
|
||||
followUpInteractionSource?: "typed" | "suggestion"
|
||||
},
|
||||
) => {
|
||||
text = text.trim()
|
||||
const currentAsk = getCurrentAskForInteraction()
|
||||
|
||||
if (text || images.length > 0) {
|
||||
// Intercept when the active provider is retired — show a
|
||||
|
|
@ -649,12 +760,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// - API request in progress (isStreaming)
|
||||
// - Queue has items (preserve message order during drain)
|
||||
// - Command is running (command_output) - user's message should be queued for AI, not sent to terminal
|
||||
if (
|
||||
sendingDisabled ||
|
||||
isStreaming ||
|
||||
messageQueue.length > 0 ||
|
||||
clineAskRef.current === "command_output"
|
||||
) {
|
||||
if (sendingDisabled || isStreaming || messageQueue.length > 0 || currentAsk === "command_output") {
|
||||
try {
|
||||
console.log("queueMessage", text, images)
|
||||
vscode.postMessage({ type: "queueMessage", text, images })
|
||||
|
|
@ -669,20 +775,31 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
return
|
||||
}
|
||||
|
||||
if (currentAsk === "followup") {
|
||||
const isFollowUpSuggestionSource = options?.followUpInteractionSource === "suggestion"
|
||||
|
||||
if (!isFollowUpSuggestionSource && !tryBeginFollowUpSuggestionActionPending()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFollowUpSuggestionSource) {
|
||||
emitFollowUpInteractionMarker({
|
||||
stage: "click",
|
||||
followUpTs:
|
||||
messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup")?.ts ?? null,
|
||||
source: "chat_view",
|
||||
})
|
||||
markFollowUpAsAnswered()
|
||||
}
|
||||
}
|
||||
|
||||
// Mark that user has responded - this prevents any pending auto-approvals.
|
||||
userRespondedRef.current = true
|
||||
|
||||
if (messagesRef.current.length === 0) {
|
||||
vscode.postMessage({ type: "newTask", text, images })
|
||||
} else if (clineAskRef.current) {
|
||||
if (clineAskRef.current === "followup") {
|
||||
markFollowUpAsAnswered()
|
||||
}
|
||||
|
||||
// Use clineAskRef.current
|
||||
switch (
|
||||
clineAskRef.current // Use clineAskRef.current
|
||||
) {
|
||||
} else if (currentAsk) {
|
||||
switch (currentAsk) {
|
||||
case "followup":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
|
|
@ -712,11 +829,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
[
|
||||
handleChatReset,
|
||||
markFollowUpAsAnswered,
|
||||
tryBeginFollowUpSuggestionActionPending,
|
||||
sendingDisabled,
|
||||
isStreaming,
|
||||
messageQueue.length,
|
||||
getCurrentAskForInteraction,
|
||||
apiConfiguration?.apiProvider,
|
||||
], // messagesRef and clineAskRef are stable
|
||||
],
|
||||
)
|
||||
|
||||
const handleSetChatBoxMessage = useCallback(
|
||||
|
|
@ -759,13 +878,53 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}, [inputValue, selectedImages])
|
||||
|
||||
const transitionActionHandlersToPendingState = useCallback(() => {
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
}, [])
|
||||
|
||||
const beginActionResolutionTransition = useCallback(() => {
|
||||
transitionActionHandlersToPendingState()
|
||||
userRespondedRef.current = true
|
||||
}, [transitionActionHandlersToPendingState])
|
||||
|
||||
const postAskResponseWithOptionalInput = useCallback(
|
||||
(askResponse: "yesButtonClicked" | "noButtonClicked", text?: string, images?: string[]) => {
|
||||
if (text || (images && images.length > 0)) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse,
|
||||
text,
|
||||
images,
|
||||
})
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
return
|
||||
}
|
||||
|
||||
vscode.postMessage({ type: "askResponse", askResponse })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// This logic depends on the useEffect[messages] above to set clineAsk,
|
||||
// after which buttons are shown and we then send an askResponse to the
|
||||
// extension.
|
||||
const handlePrimaryButtonClick = useCallback(
|
||||
(text?: string, images?: string[]) => {
|
||||
// Mark that user has responded
|
||||
userRespondedRef.current = true
|
||||
if (!clineAsk) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!tryBeginApprovalActionPending()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply optimistic pending UI immediately on click.
|
||||
beginActionResolutionTransition()
|
||||
|
||||
const trimmedInput = text?.trim()
|
||||
|
||||
|
|
@ -776,46 +935,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
case "mistake_limit_reached":
|
||||
// Only send text/images if they exist
|
||||
if (trimmedInput || (images && images.length > 0)) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "yesButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
})
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
} else {
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
|
||||
}
|
||||
postAskResponseWithOptionalInput("yesButtonClicked", trimmedInput, images)
|
||||
break
|
||||
case "resume_task":
|
||||
// For completed subtasks (tasks with a parentTaskId and a completion_result),
|
||||
// start a new task instead of resuming since the subtask is done
|
||||
const isCompletedSubtaskForClick =
|
||||
currentTaskItem?.parentTaskId &&
|
||||
messagesRef.current.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
|
||||
)
|
||||
if (isCompletedSubtaskForClick) {
|
||||
if (isCurrentSubtaskCompleted()) {
|
||||
startNewTask()
|
||||
} else {
|
||||
// Only send text/images if they exist
|
||||
if (trimmedInput || (images && images.length > 0)) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "yesButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
})
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
} else {
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
|
||||
}
|
||||
postAskResponseWithOptionalInput("yesButtonClicked", trimmedInput, images)
|
||||
}
|
||||
break
|
||||
case "completion_result":
|
||||
|
|
@ -827,20 +953,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" })
|
||||
break
|
||||
}
|
||||
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
},
|
||||
[clineAsk, startNewTask, currentTaskItem?.parentTaskId],
|
||||
[
|
||||
clineAsk,
|
||||
startNewTask,
|
||||
isCurrentSubtaskCompleted,
|
||||
tryBeginApprovalActionPending,
|
||||
beginActionResolutionTransition,
|
||||
postAskResponseWithOptionalInput,
|
||||
],
|
||||
)
|
||||
|
||||
const handleSecondaryButtonClick = useCallback(
|
||||
(text?: string, images?: string[]) => {
|
||||
// Mark that user has responded
|
||||
userRespondedRef.current = true
|
||||
if (!clineAsk) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!tryBeginApprovalActionPending()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply optimistic pending UI immediately on click to avoid stale controls.
|
||||
beginActionResolutionTransition()
|
||||
|
||||
const trimmedInput = text?.trim()
|
||||
|
||||
|
|
@ -860,31 +995,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
// Only send text/images if they exist
|
||||
if (trimmedInput || (images && images.length > 0)) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "noButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
})
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
} else {
|
||||
// Responds to the API with a "This operation failed" and lets it try again
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
|
||||
}
|
||||
postAskResponseWithOptionalInput("noButtonClicked", trimmedInput, images)
|
||||
break
|
||||
case "command_output":
|
||||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
},
|
||||
[clineAsk, startNewTask, isStreaming, setDidClickCancel],
|
||||
[
|
||||
clineAsk,
|
||||
startNewTask,
|
||||
isStreaming,
|
||||
setDidClickCancel,
|
||||
tryBeginApprovalActionPending,
|
||||
beginActionResolutionTransition,
|
||||
postAskResponseWithOptionalInput,
|
||||
],
|
||||
)
|
||||
|
||||
const { info: model } = useSelectedModel(apiConfiguration)
|
||||
|
|
@ -1094,9 +1220,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
useEffect(() => {
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const cache = everVisibleMessagesTsRef.current
|
||||
const currentMessageIds = new Set(modifiedMessages.map((m: ClineMessage) => m.ts))
|
||||
const viewportMessages = visibleMessages.slice(Math.max(0, visibleMessages.length - 100))
|
||||
const viewportMessageIds = new Set(viewportMessages.map((m: ClineMessage) => m.ts))
|
||||
const { currentMessageIds, viewportMessageIds } = cacheCleanupSnapshotRef.current
|
||||
|
||||
cache.forEach((_value: boolean, key: number) => {
|
||||
if (!currentMessageIds.has(key) && !viewportMessageIds.has(key)) {
|
||||
|
|
@ -1106,7 +1230,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}, 60000)
|
||||
|
||||
return () => clearInterval(cleanupInterval)
|
||||
}, [modifiedMessages, visibleMessages])
|
||||
}, [])
|
||||
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
|
|
@ -1325,15 +1449,73 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const result = batchConsecutive(listFilesBatched, isEditFileAsk, synthesizeEditFileBatch)
|
||||
|
||||
if (isCondensing) {
|
||||
const syntheticCondenseTs = task?.ts ? -Math.abs(task.ts) : Number.MIN_SAFE_INTEGER + 1
|
||||
result.push({
|
||||
type: "say",
|
||||
say: "condense_context",
|
||||
ts: Date.now(),
|
||||
ts: syntheticCondenseTs,
|
||||
partial: true,
|
||||
} as ClineMessage)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [isCondensing, visibleMessages, isBrowserSessionMessage])
|
||||
}, [isCondensing, visibleMessages, isBrowserSessionMessage, task?.ts])
|
||||
|
||||
const rowRenderMetadata = useMemo(() => {
|
||||
const browserActions: ClineMessage[] = []
|
||||
const browserActionResults: ClineMessage[] = []
|
||||
|
||||
for (const message of modifiedMessages) {
|
||||
if (message.type !== "say") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.say === "browser_action") {
|
||||
browserActions.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.say === "browser_action_result") {
|
||||
browserActionResults.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
const browserActionIndexByTs = new Map<number, number>()
|
||||
const nextBrowserActionResultByTs = new Map<number, ClineMessage | undefined>()
|
||||
let resultCursor = 0
|
||||
|
||||
for (let actionIndex = 0; actionIndex < browserActions.length; actionIndex++) {
|
||||
const actionMessage = browserActions[actionIndex]
|
||||
browserActionIndexByTs.set(actionMessage.ts, actionIndex + 1)
|
||||
|
||||
while (
|
||||
resultCursor < browserActionResults.length &&
|
||||
browserActionResults[resultCursor].ts <= actionMessage.ts
|
||||
) {
|
||||
resultCursor++
|
||||
}
|
||||
|
||||
nextBrowserActionResultByTs.set(actionMessage.ts, browserActionResults[resultCursor])
|
||||
}
|
||||
|
||||
return {
|
||||
hasCheckpoint: modifiedMessages.some((message) => message.say === "checkpoint_saved"),
|
||||
lastModifiedMessage: modifiedMessages.at(-1),
|
||||
totalBrowserActions: browserActions.length,
|
||||
browserActionIndexByTs,
|
||||
nextBrowserActionResultByTs,
|
||||
}
|
||||
}, [modifiedMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const viewportStart = Math.max(0, visibleMessages.length - 100)
|
||||
|
||||
cacheCleanupSnapshotRef.current = {
|
||||
currentMessageIds: new Set(modifiedMessages.map((message: ClineMessage) => message.ts)),
|
||||
viewportMessageIds: new Set(
|
||||
visibleMessages.slice(viewportStart).map((message: ClineMessage) => message.ts),
|
||||
),
|
||||
}
|
||||
}, [modifiedMessages, visibleMessages])
|
||||
|
||||
// scrolling
|
||||
|
||||
|
|
@ -1384,7 +1566,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (isTaller) {
|
||||
scrollToBottomSmooth()
|
||||
} else {
|
||||
setTimeout(() => scrollToBottomAuto(), 0)
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1439,13 +1621,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const handleSuggestionClickInRow = useCallback(
|
||||
(suggestion: SuggestionItem, event?: React.MouseEvent) => {
|
||||
const currentAsk = getCurrentAskForInteraction()
|
||||
const isFollowUpSuggestionSubmit = currentAsk === "followup" && !event?.shiftKey
|
||||
|
||||
if (isFollowUpSuggestionSubmit && !tryBeginFollowUpSuggestionActionPending()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (isFollowUpSuggestionSubmit) {
|
||||
markFollowUpAsAnswered()
|
||||
}
|
||||
|
||||
|
|
@ -1468,12 +1657,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// Don't clear the input value when sending a follow-up choice
|
||||
// The message should be sent but the text area should preserve what the user typed
|
||||
const preservedInput = inputValueRef.current
|
||||
handleSendMessage(suggestion.answer, [])
|
||||
handleSendMessage(suggestion.answer, [], { followUpInteractionSource: "suggestion" })
|
||||
// Restore the input value after sending
|
||||
setInputValue(preservedInput)
|
||||
}
|
||||
},
|
||||
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk, markFollowUpAsAnswered],
|
||||
[
|
||||
handleSendMessage,
|
||||
setInputValue,
|
||||
switchToMode,
|
||||
alwaysAllowModeSwitch,
|
||||
markFollowUpAsAnswered,
|
||||
getCurrentAskForInteraction,
|
||||
tryBeginFollowUpSuggestionActionPending,
|
||||
],
|
||||
)
|
||||
|
||||
const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
|
||||
|
|
@ -1483,19 +1680,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const itemContent = useCallback(
|
||||
(index: number, messageOrGroup: ClineMessage) => {
|
||||
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
|
||||
|
||||
// Check if this is a browser action message
|
||||
if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_action") {
|
||||
// Find the corresponding result message by looking for the next browser_action_result after this action's timestamp
|
||||
const nextMessage = modifiedMessages.find(
|
||||
(m) => m.ts > messageOrGroup.ts && m.say === "browser_action_result",
|
||||
)
|
||||
|
||||
// Calculate action index and total count
|
||||
const browserActions = modifiedMessages.filter((m) => m.say === "browser_action")
|
||||
const actionIndex = browserActions.findIndex((m) => m.ts === messageOrGroup.ts) + 1
|
||||
const totalActions = browserActions.length
|
||||
const nextMessage = rowRenderMetadata.nextBrowserActionResultByTs.get(messageOrGroup.ts)
|
||||
const actionIndex = rowRenderMetadata.browserActionIndexByTs.get(messageOrGroup.ts) ?? 1
|
||||
const totalActions = rowRenderMetadata.totalBrowserActions
|
||||
|
||||
return (
|
||||
<BrowserActionRow
|
||||
|
|
@ -1520,13 +1709,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
message={messageOrGroup}
|
||||
isExpanded={expandedRows[messageOrGroup.ts] || false}
|
||||
onToggleExpand={toggleRowExpansion} // This was already stabilized
|
||||
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
|
||||
lastModifiedMessage={rowRenderMetadata.lastModifiedMessage}
|
||||
isLast={index === groupedMessages.length - 1} // Original direct access
|
||||
onHeightChange={handleRowHeightChange}
|
||||
isStreaming={isStreaming}
|
||||
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
|
||||
onBatchFileResponse={handleBatchFileResponse}
|
||||
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
|
||||
isFollowUpAnswered={
|
||||
messageOrGroup.isAnswered === true ||
|
||||
messageOrGroup.ts === currentFollowUpTs ||
|
||||
(messageOrGroup.type === "ask" &&
|
||||
messageOrGroup.ask === "followup" &&
|
||||
messageOrGroup.ts !== activeFollowUpTs)
|
||||
}
|
||||
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
|
||||
editable={
|
||||
messageOrGroup.type === "ask" &&
|
||||
|
|
@ -1543,20 +1738,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
|
||||
})()
|
||||
}
|
||||
hasCheckpoint={hasCheckpoint}
|
||||
hasCheckpoint={rowRenderMetadata.hasCheckpoint}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
expandedRows,
|
||||
toggleRowExpansion,
|
||||
modifiedMessages,
|
||||
rowRenderMetadata,
|
||||
groupedMessages.length,
|
||||
handleRowHeightChange,
|
||||
isStreaming,
|
||||
handleSuggestionClickInRow,
|
||||
handleBatchFileResponse,
|
||||
currentFollowUpTs,
|
||||
activeFollowUpTs,
|
||||
isFollowUpAutoApprovalPaused,
|
||||
enableButtons,
|
||||
primaryButtonText,
|
||||
|
|
@ -1612,10 +1808,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
useImperativeHandle(ref, () => ({
|
||||
acceptInput: () => {
|
||||
const hasInput = inputValue.trim() || selectedImages.length > 0
|
||||
const currentAsk = getCurrentAskForInteraction()
|
||||
|
||||
// Special case: during command_output, queue the message instead of
|
||||
// triggering the primary button action (which would lose the message)
|
||||
if (clineAskRef.current === "command_output" && hasInput) {
|
||||
if (currentAsk === "command_output" && hasInput) {
|
||||
vscode.postMessage({ type: "queueMessage", text: inputValue.trim(), images: selectedImages })
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
|
|
@ -1640,6 +1837,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
|
||||
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText
|
||||
const areActionButtonsEnabled = enableButtons && !isApprovalActionPending
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1810,7 +2008,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!enableButtons}
|
||||
disabled={!areActionButtonsEnabled}
|
||||
aria-busy={isApprovalActionPending || undefined}
|
||||
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
|
|
@ -1832,7 +2031,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!enableButtons}
|
||||
disabled={!areActionButtonsEnabled}
|
||||
aria-busy={isApprovalActionPending || undefined}
|
||||
className="flex-1 ml-[6px]"
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{secondaryButtonText}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ClipboardCopy, Timer } from "lucide-react"
|
||||
|
||||
import { Button, StandardTooltip } from "@/components/ui"
|
||||
|
|
@ -7,6 +7,8 @@ import { useAppTranslation } from "@src/i18n/TranslationContext"
|
|||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { SuggestionItem } from "@roo-code/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emitFollowUpInteractionMarker } from "./followUpInteractionInstrumentation"
|
||||
import { usePendingActionContract } from "./usePendingActionContract"
|
||||
|
||||
const DEFAULT_FOLLOWUP_TIMEOUT_MS = 60000
|
||||
const COUNTDOWN_INTERVAL_MS = 1000
|
||||
|
|
@ -31,7 +33,36 @@ export const FollowUpSuggest = ({
|
|||
const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [suggestionSelected, setSuggestionSelected] = useState(false)
|
||||
const [hasReachedTerminalState, setHasReachedTerminalState] = useState(isAnswered)
|
||||
const {
|
||||
isPending: isSuggestionActionPending,
|
||||
tryBeginPendingAction: tryBeginSuggestionActionPending,
|
||||
clearPendingAction: clearSuggestionActionPending,
|
||||
} = usePendingActionContract()
|
||||
const { t } = useAppTranslation()
|
||||
const isFollowUpTerminal = hasReachedTerminalState || suggestionSelected
|
||||
const shouldShowCountdown = countdown !== null
|
||||
const followUpTerminalRef = useRef<boolean>(isFollowUpTerminal)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnswered) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasReachedTerminalState(true)
|
||||
}, [isAnswered])
|
||||
|
||||
useEffect(() => {
|
||||
followUpTerminalRef.current = isFollowUpTerminal
|
||||
}, [isFollowUpTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuggestionActionPending || !isFollowUpTerminal) {
|
||||
return
|
||||
}
|
||||
|
||||
clearSuggestionActionPending()
|
||||
}, [isSuggestionActionPending, isFollowUpTerminal, clearSuggestionActionPending])
|
||||
|
||||
// Start countdown timer when auto-approval is enabled for follow-up questions
|
||||
useEffect(() => {
|
||||
|
|
@ -41,8 +72,7 @@ export const FollowUpSuggest = ({
|
|||
autoApprovalEnabled &&
|
||||
alwaysAllowFollowupQuestions &&
|
||||
suggestions.length > 0 &&
|
||||
!suggestionSelected &&
|
||||
!isAnswered &&
|
||||
!isFollowUpTerminal &&
|
||||
!isFollowUpAutoApprovalPaused
|
||||
) {
|
||||
// Start with the configured timeout in seconds
|
||||
|
|
@ -80,15 +110,32 @@ export const FollowUpSuggest = ({
|
|||
alwaysAllowFollowupQuestions,
|
||||
suggestions,
|
||||
followupAutoApproveTimeoutMs,
|
||||
suggestionSelected,
|
||||
isFollowUpTerminal,
|
||||
onCancelAutoApproval,
|
||||
isAnswered,
|
||||
isFollowUpAutoApprovalPaused,
|
||||
])
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion: SuggestionItem, event: React.MouseEvent) => {
|
||||
if (followUpTerminalRef.current || isSuggestionActionPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const isShiftClick = event.shiftKey
|
||||
|
||||
if (!isShiftClick && !tryBeginSuggestionActionPending()) {
|
||||
return
|
||||
}
|
||||
|
||||
emitFollowUpInteractionMarker({
|
||||
stage: "click",
|
||||
followUpTs: ts,
|
||||
source: "follow_up_suggest",
|
||||
})
|
||||
|
||||
// Mark a suggestion as selected if it's not a shift-click (which just copies to input)
|
||||
if (!event.shiftKey) {
|
||||
if (!isShiftClick) {
|
||||
followUpTerminalRef.current = true
|
||||
setHasReachedTerminalState(true)
|
||||
setSuggestionSelected(true)
|
||||
// Also notify parent component to cancel auto-approval timeout
|
||||
// This prevents race conditions between visual countdown and actual timeout
|
||||
|
|
@ -99,11 +146,26 @@ export const FollowUpSuggest = ({
|
|||
// The parent component will handle mode switching if needed
|
||||
onSuggestionClick?.(suggestion, event)
|
||||
},
|
||||
[onSuggestionClick, onCancelAutoApproval],
|
||||
[onSuggestionClick, onCancelAutoApproval, ts, isSuggestionActionPending, tryBeginSuggestionActionPending],
|
||||
)
|
||||
|
||||
const handleCopyToInputClick = useCallback(
|
||||
(suggestion: SuggestionItem, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (followUpTerminalRef.current || isSuggestionActionPending) {
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
// Cancel the auto-approve timer when edit button is clicked
|
||||
onCancelAutoApproval?.()
|
||||
// Simulate shift-click by directly calling the handler with shiftKey=true.
|
||||
onSuggestionClick?.(suggestion, { ...event, shiftKey: true })
|
||||
},
|
||||
[isSuggestionActionPending, onCancelAutoApproval, onSuggestionClick],
|
||||
)
|
||||
|
||||
// Don't render if there are no suggestions or no click handler.
|
||||
if (!suggestions?.length || !onSuggestionClick) {
|
||||
if (!suggestions?.length || !onSuggestionClick || isFollowUpTerminal) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -116,19 +178,19 @@ export const FollowUpSuggest = ({
|
|||
<div key={`${suggestion.answer}-${ts}`} className="w-full relative group">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isSuggestionActionPending}
|
||||
aria-busy={isSuggestionActionPending || undefined}
|
||||
className={cn(
|
||||
"text-left whitespace-normal break-words w-full h-auto px-3 py-2 justify-start pr-8 rounded-xl",
|
||||
isFirstSuggestion &&
|
||||
countdown !== null &&
|
||||
!suggestionSelected &&
|
||||
!isAnswered &&
|
||||
shouldShowCountdown &&
|
||||
"border-vscode-foreground/60 rounded-b-none -mb-1",
|
||||
)}
|
||||
onClick={(event) => handleSuggestionClick(suggestion, event)}
|
||||
aria-label={suggestion.answer}>
|
||||
{suggestion.answer}
|
||||
</Button>
|
||||
{isFirstSuggestion && countdown !== null && !suggestionSelected && !isAnswered && (
|
||||
{isFirstSuggestion && shouldShowCountdown && (
|
||||
<p className="rounded-b-xl border-1 border-t-0 border-vscode-foreground/60 text-vscode-descriptionForeground text-xs m-0 mt-1 px-3 pt-2 pb-2">
|
||||
<Timer className="size-3 inline-block -mt-0.5 mr-1 animate-pulse" />
|
||||
{t("chat:followUpSuggest.timerPrefix", { seconds: countdown })}
|
||||
|
|
@ -143,14 +205,7 @@ export const FollowUpSuggest = ({
|
|||
<StandardTooltip content={t("chat:followUpSuggest.copyToInput")}>
|
||||
<div
|
||||
className="absolute cursor-pointer top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity bg-vscode-input-background px-0.5 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Cancel the auto-approve timer when edit button is clicked
|
||||
setSuggestionSelected(true)
|
||||
onCancelAutoApproval?.()
|
||||
// Simulate shift-click by directly calling the handler with shiftKey=true.
|
||||
onSuggestionClick?.(suggestion, { ...e, shiftKey: true })
|
||||
}}>
|
||||
onClick={(event) => handleCopyToInputClick(suggestion, event)}>
|
||||
<ClipboardCopy className="w-4" />
|
||||
</div>
|
||||
</StandardTooltip>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,12 @@
|
|||
import React, { createContext, useContext } from "react"
|
||||
import { render, screen, act } from "@testing-library/react"
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react"
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip"
|
||||
|
||||
import { FollowUpSuggest } from "../FollowUpSuggest"
|
||||
import {
|
||||
setFollowUpInteractionInstrumentationSink,
|
||||
type FollowUpInteractionMarker,
|
||||
} from "../followUpInteractionInstrumentation"
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("@src/i18n/TranslationContext", () => ({
|
||||
|
|
@ -81,6 +85,7 @@ describe("FollowUpSuggest", () => {
|
|||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
setFollowUpInteractionInstrumentationSink(undefined)
|
||||
})
|
||||
|
||||
it("should display countdown timer when auto-approval is enabled", () => {
|
||||
|
|
@ -289,6 +294,303 @@ describe("FollowUpSuggest", () => {
|
|||
expect(mockOnCancelAutoApproval).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should hide follow-up controls immediately after accepting a suggestion", () => {
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledWith(mockSuggestions[0], expect.any(Object))
|
||||
expect(mockOnCancelAutoApproval).toHaveBeenCalled()
|
||||
|
||||
// Terminal follow-up state should remove actionable controls on the next render cycle.
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Second suggestion" })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Selecting in \d+s/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("emits deterministic click instrumentation marker when a suggestion is clicked", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
|
||||
expect(markers).toHaveLength(1)
|
||||
expect(markers[0]).toMatchObject({
|
||||
stage: "click",
|
||||
followUpTs: 123,
|
||||
source: "follow_up_suggest",
|
||||
})
|
||||
expect(typeof markers[0].atMs).toBe("number")
|
||||
})
|
||||
|
||||
it("prevents duplicate non-shift clicks from re-firing handler and instrumentation after terminal transition", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
// Immediate second click before React commit should be blocked by followUpTerminalRef.
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnCancelAutoApproval).toHaveBeenCalled()
|
||||
expect(markers.map((marker) => marker.stage)).toEqual(["click"])
|
||||
expect(markers[0]).toMatchObject({
|
||||
followUpTs: 123,
|
||||
source: "follow_up_suggest",
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Verifies state-machine style suppression when a second suggestion click is attempted
|
||||
* before the first click's terminal transition has fully committed to the DOM.
|
||||
*/
|
||||
it("rejects rapid cross-suggestion clicks while pending, allowing only the first dispatch", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
const secondSuggestionButton = screen.getByRole("button", { name: "Second suggestion" })
|
||||
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
// Attempt a competing click against another option in the same interaction turn.
|
||||
fireEvent.click(secondSuggestionButton)
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledWith(mockSuggestions[0], expect.any(Object))
|
||||
expect(markers.map((marker) => marker.stage)).toEqual(["click"])
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Second suggestion" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps follow-up actionable for shift-click copy behavior, then transitions terminal on normal click", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
|
||||
fireEvent.click(firstSuggestionButton, { shiftKey: true })
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSuggestionClick).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockSuggestions[0],
|
||||
expect.objectContaining({ shiftKey: true }),
|
||||
)
|
||||
// Shift-click should not terminalize the follow-up controls.
|
||||
expect(screen.getByRole("button", { name: "First suggestion" })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "First suggestion" }))
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnSuggestionClick).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockSuggestions[0],
|
||||
expect.objectContaining({ shiftKey: false }),
|
||||
)
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
expect(markers.map((marker) => marker.stage)).toEqual(["click", "click"])
|
||||
})
|
||||
|
||||
/**
|
||||
* Ensures once terminalized, rerenders cannot regress the component back to an actionable state.
|
||||
* This guards against invalid/out-of-order lifecycle progression under parent rerenders.
|
||||
*/
|
||||
it("accepts only forward follow-up lifecycle progression under rerender pressure", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
const { rerender } = renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const firstSuggestionButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
fireEvent.click(firstSuggestionButton)
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
expect(markers.map((marker) => marker.stage)).toEqual(["click"])
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
|
||||
const rerenderSequence: boolean[] = [false, true, false]
|
||||
for (const isAnswered of rerenderSequence) {
|
||||
rerender(
|
||||
<TestExtensionStateProvider value={defaultTestState}>
|
||||
<TooltipProvider>
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={isAnswered}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</TestExtensionStateProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Second suggestion" })).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("keeps answered state forward-only for a single follow-up interaction even if parent rerenders isAnswered out-of-order", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
const { rerender } = renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={true}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<TestExtensionStateProvider value={defaultTestState}>
|
||||
<TooltipProvider>
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</TestExtensionStateProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Second suggestion" })).not.toBeInTheDocument()
|
||||
expect(markers).toHaveLength(0)
|
||||
expect(mockOnSuggestionClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("allows deterministic retry on remount without stale disabled or hidden controls", () => {
|
||||
const markers: FollowUpInteractionMarker[] = []
|
||||
setFollowUpInteractionInstrumentationSink((marker) => {
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
const firstRender = renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={123}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "First suggestion" }))
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole("button", { name: "First suggestion" })).not.toBeInTheDocument()
|
||||
|
||||
firstRender.unmount()
|
||||
|
||||
renderWithTestProviders(
|
||||
<FollowUpSuggest
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={mockOnSuggestionClick}
|
||||
ts={456}
|
||||
onCancelAutoApproval={mockOnCancelAutoApproval}
|
||||
isAnswered={false}
|
||||
/>,
|
||||
defaultTestState,
|
||||
)
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "First suggestion" })
|
||||
expect(retryButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
expect(mockOnSuggestionClick).toHaveBeenCalledTimes(2)
|
||||
expect(markers.map((marker) => marker.stage)).toEqual(["click", "click"])
|
||||
expect(markers.map((marker) => marker.followUpTs)).toEqual([123, 456])
|
||||
})
|
||||
|
||||
it("should handle race condition when timeout fires but user has already responded", () => {
|
||||
// This test simulates the scenario where:
|
||||
// 1. Auto-approval countdown starts
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
export type FollowUpInteractionStage = "click" | "pending_render" | "settle" | "clear"
|
||||
|
||||
export interface FollowUpInteractionMarker {
|
||||
stage: FollowUpInteractionStage
|
||||
followUpTs: number | null
|
||||
source: "follow_up_suggest" | "chat_view"
|
||||
atMs: number
|
||||
}
|
||||
|
||||
type FollowUpInteractionSink = (marker: FollowUpInteractionMarker) => void
|
||||
|
||||
let followUpInteractionSink: FollowUpInteractionSink | undefined
|
||||
let lastMarkerAtMs = 0
|
||||
|
||||
const getMonotonicNowMs = (): number => {
|
||||
const rawNow =
|
||||
typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now()
|
||||
|
||||
if (!Number.isFinite(rawNow)) {
|
||||
lastMarkerAtMs += 1
|
||||
return lastMarkerAtMs
|
||||
}
|
||||
|
||||
lastMarkerAtMs = Math.max(lastMarkerAtMs, rawNow)
|
||||
return lastMarkerAtMs
|
||||
}
|
||||
|
||||
export const setFollowUpInteractionInstrumentationSink = (sink: FollowUpInteractionSink | undefined): void => {
|
||||
followUpInteractionSink = sink
|
||||
|
||||
if (!sink) {
|
||||
lastMarkerAtMs = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const emitFollowUpInteractionMarker = (marker: Omit<FollowUpInteractionMarker, "atMs">): void => {
|
||||
if (!followUpInteractionSink) {
|
||||
return
|
||||
}
|
||||
|
||||
followUpInteractionSink({
|
||||
...marker,
|
||||
atMs: getMonotonicNowMs(),
|
||||
})
|
||||
}
|
||||
33
webview-ui/src/components/chat/usePendingActionContract.ts
Normal file
33
webview-ui/src/components/chat/usePendingActionContract.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useCallback, useRef, useState } from "react"
|
||||
|
||||
export interface PendingActionContract {
|
||||
isPending: boolean
|
||||
tryBeginPendingAction: () => boolean
|
||||
clearPendingAction: () => void
|
||||
}
|
||||
|
||||
export const usePendingActionContract = (): PendingActionContract => {
|
||||
const pendingActionRef = useRef(false)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const tryBeginPendingAction = useCallback(() => {
|
||||
if (pendingActionRef.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingActionRef.current = true
|
||||
setIsPending(true)
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const clearPendingAction = useCallback(() => {
|
||||
pendingActionRef.current = false
|
||||
setIsPending(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isPending,
|
||||
tryBeginPendingAction,
|
||||
clearPendingAction,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue