Compare commits

...
Author SHA1 Message Date
Saoud Rizwan e487126de7 fix(vscode): render ask responses optimistically 2026-06-25 04:48:02 -07:00
9 changed files with 441 additions and 45 deletions
@@ -73,6 +73,7 @@ interface ChatRowProps {
onHeightChange: (isTaller: boolean) => void
inputValue?: string
sendMessageFromChatRow?: (text: string, images: string[], files: string[]) => void
onOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
onSetQuote: (text: string) => void
onCancelCommand?: () => void
mode?: Mode
@@ -137,6 +138,7 @@ export const ChatRowContent = memo(
isLast,
inputValue,
sendMessageFromChatRow,
onOptimisticUserMessage,
onSetQuote,
onCancelCommand,
mode,
@@ -1135,6 +1137,7 @@ export const ChatRowContent = memo(
(isLast && lastModifiedMessage?.ask === "followup") ||
(!selected && options && options.length > 0)
}
onOptimisticUserMessage={onOptimisticUserMessage}
options={options}
selected={selected}
/>
@@ -1196,6 +1199,7 @@ export const ChatRowContent = memo(
(isLast && lastModifiedMessage?.ask === "plan_mode_respond") ||
(!selected && options && options.length > 0)
}
onOptimisticUserMessage={onOptimisticUserMessage}
options={options}
selected={selected}
/>
@@ -3,8 +3,9 @@ import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineErrorRetryMessages } from "@shared/combineErrorRetryMessages"
import { combineHookSequences } from "@shared/combineHookSequences"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useShowNavbar } from "@/context/PlatformContext"
@@ -41,6 +42,23 @@ interface ChatViewProps {
const MAX_IMAGES_AND_FILES_PER_MESSAGE = CHAT_CONSTANTS.MAX_IMAGES_AND_FILES_PER_MESSAGE
const QUICK_WINS_HISTORY_THRESHOLD = 3
const sameStringList = (a?: string[], b?: string[]) => {
const left = a ?? []
const right = b ?? []
return left.length === right.length && left.every((value, index) => value === right[index])
}
const hasAuthoritativeUserMessage = (messages: ClineMessage[], optimisticMessage: ClineMessage) => {
return messages.some(
(message) =>
message.type === "say" &&
message.say === "user_feedback" &&
message.text === optimisticMessage.text &&
sameStringList(message.images, optimisticMessage.images) &&
sameStringList(message.files, optimisticMessage.files),
)
}
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const showNavbar = useShowNavbar()
const {
@@ -55,15 +73,58 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} = useExtensionState()
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
const shouldShowQuickWins = isProdHostedApp && (!taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD)
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ClineMessage[]>([])
const optimisticMessageIdRef = useRef(0)
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const addOptimisticUserMessage = useCallback((text: string, images?: string[], files?: string[]) => {
const hasText = !!text.trim()
const hasImages = !!images?.length
const hasFiles = !!files?.length
if (!hasText && !hasImages && !hasFiles) {
return () => {}
}
const optimisticMessage: ClineMessage = {
ts: -(Date.now() * 1000 + optimisticMessageIdRef.current++),
type: "say",
say: "user_feedback",
text,
images,
files,
partial: false,
}
setOptimisticUserMessages((current) => [...current, optimisticMessage])
return () => {
setOptimisticUserMessages((current) => current.filter((message) => message.ts !== optimisticMessage.ts))
}
}, [])
useEffect(() => {
setOptimisticUserMessages([])
}, [task?.ts])
useEffect(() => {
setOptimisticUserMessages((current) =>
current.filter((optimisticMessage) => !hasAuthoritativeUserMessage(messages, optimisticMessage)),
)
}, [messages])
const displayMessages = useMemo(() => {
if (!task || optimisticUserMessages.length === 0) {
return messages
}
return [...messages, ...optimisticUserMessages]
}, [messages, optimisticUserMessages, task])
const modifiedMessages = useMemo(() => {
const slicedMessages = messages.slice(1)
const slicedMessages = displayMessages.slice(1)
// Only combine hook sequences if hooks are enabled
const withHooks = hooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
return combineErrorRetryMessages(combineApiRequests(combineCommandSequences(withHooks)))
}, [messages, hooksEnabled])
}, [displayMessages, hooksEnabled])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -86,10 +147,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const lastAppliedCheckpointRestoreSessionId = useRef<string | undefined>(checkpointRestoreInput?.sessionId)
useEffect(() => {
if (
!checkpointRestoreInput ||
checkpointRestoreInput.sessionId === lastAppliedCheckpointRestoreSessionId.current
) {
if (!checkpointRestoreInput || checkpointRestoreInput.sessionId === lastAppliedCheckpointRestoreSessionId.current) {
return
}
lastAppliedCheckpointRestoreSessionId.current = checkpointRestoreInput.sessionId
@@ -192,7 +250,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// handleFocusChange is already provided by chatState
// Use message handlers hook
const messageHandlers = useMessageHandlers(messages, chatState)
const messageHandlers = useMessageHandlers(messages, chatState, { addOptimisticUserMessage })
const { selectedModelInfo } = useNormalizedApiConfiguration(mode)
@@ -315,7 +373,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [visibleMessages])
// Use scroll behavior hook
const scrollBehavior = useScrollBehavior(messages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const scrollBehavior = useScrollBehavior(displayMessages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const placeholderText = useMemo(() => {
const text = task ? "Type a message..." : "Type your task here..."
@@ -0,0 +1,89 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { OptionsButtons } from "./OptionsButtons"
const askResponse = vi.fn().mockResolvedValue(undefined)
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
askResponse: (req: unknown) => askResponse(req),
},
}))
vi.mock("@shared/proto/cline/task", () => ({
AskResponseRequest: { create: (x: unknown) => x },
}))
describe("OptionsButtons", () => {
beforeEach(() => {
askResponse.mockReset()
askResponse.mockResolvedValue(undefined)
})
it("latches the selected option and renders optimistic feedback while askResponse is pending", async () => {
let resolveAskResponse: () => void = () => {}
const removeOptimisticUserMessage = vi.fn()
const onOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
askResponse.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveAskResponse = resolve
}),
)
render(
<OptionsButtons
inputValue="extra detail"
isActive
onOptimisticUserMessage={onOptimisticUserMessage}
options={["First", "Second"]}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "First" }))
await Promise.resolve()
})
fireEvent.click(screen.getByRole("button", { name: "Second" }))
expect(askResponse).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "messageResponse",
text: "First: extra detail",
}),
)
expect(onOptimisticUserMessage).toHaveBeenCalledWith("First: extra detail", [], [])
expect(removeOptimisticUserMessage).not.toHaveBeenCalled()
await act(async () => {
resolveAskResponse()
})
})
it("removes optimistic feedback and unlatches the option when askResponse fails", async () => {
vi.spyOn(console, "error").mockImplementationOnce(() => {})
const removeOptimisticUserMessage = vi.fn()
const onOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
askResponse.mockRejectedValueOnce(new Error("transport down")).mockResolvedValueOnce(undefined)
render(<OptionsButtons isActive onOptimisticUserMessage={onOptimisticUserMessage} options={["First", "Second"]} />)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "First" }))
await Promise.resolve()
})
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Second" }))
})
expect(removeOptimisticUserMessage).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledTimes(2)
expect(askResponse).toHaveBeenLastCalledWith(
expect.objectContaining({
responseType: "messageResponse",
text: "Second",
}),
)
})
})
@@ -1,20 +1,21 @@
import { AskResponseRequest } from "@shared/proto/cline/task"
import { useEffect, useMemo, useState } from "react"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { TaskServiceClient } from "@/services/grpc-client"
const OptionButton = styled.button<{ isSelected?: boolean; isNotSelectable?: boolean }>`
const OptionButton = styled.button<{ $isSelected?: boolean; $isNotSelectable?: boolean }>`
padding: 8px 12px;
background: ${(props) => (props.isSelected ? "var(--vscode-focusBorder)" : CODE_BLOCK_BG_COLOR)};
color: ${(props) => (props.isSelected ? "white" : "var(--vscode-input-foreground)")};
background: ${(props) => (props.$isSelected ? "var(--vscode-focusBorder)" : CODE_BLOCK_BG_COLOR)};
color: ${(props) => (props.$isSelected ? "white" : "var(--vscode-input-foreground)")};
border: 1px solid var(--vscode-editorGroup-border);
border-radius: 2px;
cursor: ${(props) => (props.isNotSelectable ? "default" : "pointer")};
cursor: ${(props) => (props.$isNotSelectable ? "default" : "pointer")};
text-align: left;
font-size: 12px;
${(props) =>
!props.isNotSelectable &&
!props.$isNotSelectable &&
`
&:hover {
background: var(--vscode-focusBorder);
@@ -28,18 +29,27 @@ export const OptionsButtons = ({
selected,
isActive,
inputValue,
onOptimisticUserMessage,
}: {
options?: string[]
selected?: string
isActive?: boolean
inputValue?: string
onOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
}) => {
const [pendingSelected, setPendingSelected] = useState<string | undefined>()
const optionsKey = useMemo(() => options?.join("\0") ?? "", [options])
const effectiveSelected = selected ?? pendingSelected
const hasSelected = effectiveSelected !== undefined && !!options?.includes(effectiveSelected)
useEffect(() => {
setPendingSelected(undefined)
}, [isActive, selected, optionsKey])
if (!options?.length) {
return null
}
const hasSelected = selected !== undefined && options.includes(selected)
return (
<div
style={{
@@ -52,24 +62,29 @@ export const OptionsButtons = ({
</div> */}
{options.map((option, index) => (
<OptionButton
$isNotSelectable={hasSelected || !isActive}
$isSelected={option === effectiveSelected}
className="options-button"
id={`options-button-${index}`}
isNotSelectable={hasSelected || !isActive}
isSelected={option === selected}
key={index}
key={option}
onClick={async () => {
if (hasSelected || !isActive) {
return
}
const responseText = option + (inputValue ? `: ${inputValue?.trim()}` : "")
setPendingSelected(option)
const removeOptimisticMessage = onOptimisticUserMessage?.(responseText, [], []) ?? (() => {})
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "messageResponse",
text: option + (inputValue ? `: ${inputValue?.trim()}` : ""),
text: responseText,
images: [],
}),
)
} catch (error) {
removeOptimisticMessage()
setPendingSelected(undefined)
console.error("Error sending option response:", error)
}
}}>
@@ -18,9 +18,10 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
const [editedText, setEditedText] = useState(text ?? "")
const [isSaving, setIsSaving] = useState(false)
const highlightedText = useMemo(() => highlightText(text), [text])
const canEditMessage = !!messageTs && messageTs > 0
const handleSave = async () => {
if (!messageTs || isSaving) {
if (!canEditMessage || !messageTs || isSaving) {
return
}
setIsSaving(true)
@@ -48,7 +49,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
{messageTs && !isEditing && (
{canEditMessage && !isEditing && (
<button
aria-label="Edit and regenerate from this message"
className="absolute right-1.5 top-1.5 opacity-0 group-hover:opacity-80 hover:opacity-100 bg-transparent border-0 text-badge-foreground cursor-pointer p-1"
@@ -115,6 +115,7 @@ const MessageRenderer: React.FC<MessageRendererProps> = ({
mode={mode}
onCancelCommand={() => messageHandlers.executeButtonAction("cancel")}
onHeightChange={onHeightChange}
onOptimisticUserMessage={messageHandlers.addOptimisticUserMessage}
onSetQuote={onSetQuote}
onToggleExpand={onToggleExpand}
reasoningContent={reasoningData.reasoning}
@@ -148,6 +148,168 @@ describe("useMessageHandlers — send routing", () => {
)
})
it("clears and renders a follow-up optimistically before askResponse resolves", async () => {
mockTurnState = { phase: "completed", seq: 7 }
let resolveAskResponse: () => void = () => {}
askResponse.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveAskResponse = resolve
}),
)
const setInputValue = vi.fn()
const setActiveQuote = vi.fn()
const setSendingDisabled = vi.fn()
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const removeOptimisticUserMessage = vi.fn()
const addOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
enableButtons: true,
setInputValue,
setActiveQuote,
setSendingDisabled,
setSelectedImages,
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
let sendPromise: Promise<void> = Promise.resolve()
await act(async () => {
sendPromise = result.current.handleSendMessage("another question", ["image.png"], ["a.ts"])
await Promise.resolve()
})
expect(setInputValue).toHaveBeenCalledWith("")
expect(setActiveQuote).toHaveBeenCalledWith(null)
expect(setSendingDisabled).toHaveBeenCalledWith(true)
expect(setSelectedImages).toHaveBeenCalledWith([])
expect(setSelectedFiles).toHaveBeenCalledWith([])
expect(setEnableButtons).toHaveBeenCalledWith(false)
expect(addOptimisticUserMessage).toHaveBeenCalledWith(
expect.stringContaining("another question"),
["image.png"],
["a.ts"],
)
expect(removeOptimisticUserMessage).not.toHaveBeenCalled()
await act(async () => {
resolveAskResponse()
await sendPromise
})
})
it("removes the optimistic follow-up and restores input when askResponse fails", async () => {
mockTurnState = { phase: "completed", seq: 7 }
const error = new Error("transport down")
const setInputValue = vi.fn()
const setActiveQuote = vi.fn()
const setSendingDisabled = vi.fn()
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const removeOptimisticUserMessage = vi.fn()
const addOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
enableButtons: true,
setInputValue,
setActiveQuote,
setSendingDisabled,
setSelectedImages,
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
askResponse.mockRejectedValueOnce(error)
let caught: unknown
await act(async () => {
try {
await result.current.handleSendMessage("another question", ["image.png"], ["a.ts"])
} catch (err) {
caught = err
}
})
expect(caught).toBe(error)
expect(removeOptimisticUserMessage).toHaveBeenCalledTimes(1)
expect(setInputValue).toHaveBeenNthCalledWith(1, "")
expect(setInputValue).toHaveBeenLastCalledWith("another question")
expect(setActiveQuote).toHaveBeenNthCalledWith(1, null)
expect(setActiveQuote).toHaveBeenLastCalledWith("selected context")
expect(setSendingDisabled).toHaveBeenNthCalledWith(1, true)
expect(setSendingDisabled).toHaveBeenLastCalledWith(false)
expect(setSelectedImages).toHaveBeenNthCalledWith(1, [])
expect(setSelectedImages).toHaveBeenLastCalledWith(["image.png"])
expect(setSelectedFiles).toHaveBeenNthCalledWith(1, [])
expect(setSelectedFiles).toHaveBeenLastCalledWith(["a.ts"])
expect(setEnableButtons).toHaveBeenNthCalledWith(1, false)
expect(setEnableButtons).toHaveBeenLastCalledWith(true)
})
it("clears action response UI state before askResponse resolves", async () => {
mockTurnState = { phase: "awaiting_approval", seq: 8 }
let resolveAskResponse: () => void = () => {}
askResponse.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveAskResponse = resolve
}),
)
const setInputValue = vi.fn()
const setActiveQuote = vi.fn()
const setSendingDisabled = vi.fn()
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const addOptimisticUserMessage = vi.fn(() => vi.fn())
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
enableButtons: true,
setInputValue,
setActiveQuote,
setSendingDisabled,
setSelectedImages,
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
let actionPromise: Promise<void> = Promise.resolve()
await act(async () => {
actionPromise = result.current.executeButtonAction("reject", "not yet", ["image.png"], ["a.ts"])
await Promise.resolve()
})
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "noButtonClicked",
text: "not yet",
images: ["image.png"],
files: ["a.ts"],
}),
)
expect(setInputValue).toHaveBeenCalledWith("")
expect(setActiveQuote).toHaveBeenCalledWith(null)
expect(setSendingDisabled).toHaveBeenCalledWith(true)
expect(setSelectedImages).toHaveBeenCalledWith([])
expect(setSelectedFiles).toHaveBeenCalledWith([])
expect(setEnableButtons).toHaveBeenCalledWith(false)
expect(addOptimisticUserMessage).toHaveBeenCalledWith("not yet", ["image.png"], ["a.ts"])
await act(async () => {
resolveAskResponse()
await actionPromise
})
})
it("phase awaiting_followup also routes a follow-up to askResponse", async () => {
mockTurnState = { phase: "awaiting_followup", seq: 3 }
const { result } = renderHook(() => useMessageHandlers(completedConversation, makeChatState(completedConversation)))
@@ -7,11 +7,19 @@ import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
import type { ButtonActionType } from "../shared/buttonConfig"
import type { ChatState, MessageHandlers } from "../types/chatTypes"
interface MessageHandlerOptions {
addOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
}
/**
* Custom hook for managing message handlers
* Handles sending messages, button clicks, and task management
*/
export function useMessageHandlers(messages: ClineMessage[], chatState: ChatState): MessageHandlers {
export function useMessageHandlers(
messages: ClineMessage[],
chatState: ChatState,
options: MessageHandlerOptions = {},
): MessageHandlers {
const { backgroundCommandRunning, turnState } = useExtensionState()
const {
setInputValue,
@@ -27,6 +35,17 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
lastMessage,
} = chatState
const cancelInFlightRef = useRef(false)
const addOptimisticUserMessage = useCallback(
(text: string, images?: string[], files?: string[]) =>
options.addOptimisticUserMessage?.(text, images, files) ?? (() => {}),
[options.addOptimisticUserMessage],
)
const resetAutoScroll = useCallback(() => {
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
}, [chatState])
// Handle sending a message
const handleSendMessage = useCallback(
@@ -70,6 +89,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSelectedImages([])
setSelectedFiles([])
setEnableButtons(false)
resetAutoScroll()
}
const restorePendingMessageState = () => {
setInputValue(text)
@@ -79,6 +99,17 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSelectedFiles(files)
setEnableButtons(enableButtons)
}
const sendAskResponseOptimistically = async (request: ReturnType<typeof AskResponseRequest.create>) => {
clearSentMessageState()
const removeOptimisticMessage = addOptimisticUserMessage(messageToSend, images, files)
try {
await TaskServiceClient.askResponse(request)
} catch (error) {
removeOptimisticMessage()
restorePendingMessageState()
throw error
}
}
if (messages.length === 0) {
const request = NewTaskRequest.create({
@@ -98,7 +129,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
// 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(
await sendAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: messageToSend,
@@ -124,7 +155,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
case "new_task":
case "condense":
case "report_bug":
await TaskServiceClient.askResponse(
await sendAskResponseOptimistically(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
@@ -158,7 +189,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
if (turnAllowsFollowup || isTaskRunning) {
// Continue the conversation / interrupt with feedback.
await TaskServiceClient.askResponse(
await sendAskResponseOptimistically(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
@@ -173,11 +204,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
// New tasks clear optimistically before the RPC; the repeated success cleanup is idempotent.
if (messageSent) {
clearSentMessageState()
// Reset auto-scroll
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
}
}
},
@@ -195,6 +221,8 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
enableButtons,
setEnableButtons,
chatState,
addOptimisticUserMessage,
resetAutoScroll,
],
)
@@ -217,75 +245,104 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
async (actionType: ButtonActionType, text?: string, images?: string[], files?: string[]) => {
const trimmedInput = text?.trim()
const hasContent = trimmedInput || (images && images.length > 0) || (files && files.length > 0)
const clearActionResponseState = () => {
clearInputState()
setSendingDisabled(true)
setEnableButtons(false)
resetAutoScroll()
}
const restoreActionResponseState = () => {
setInputValue(text ?? "")
setActiveQuote(activeQuote)
setSelectedImages(images ?? [])
setSelectedFiles(files ?? [])
setSendingDisabled(sendingDisabled)
setEnableButtons(enableButtons)
}
const sendButtonAskResponseOptimistically = async (
request: ReturnType<typeof AskResponseRequest.create>,
optimisticText?: string,
) => {
clearActionResponseState()
const removeOptimisticMessage = optimisticText
? addOptimisticUserMessage(optimisticText, images, files)
: () => {}
try {
await TaskServiceClient.askResponse(request)
} catch (error) {
removeOptimisticMessage()
restoreActionResponseState()
throw error
}
}
switch (actionType) {
case "retry":
// For API retry (api_req_failed), always send simple approval without content
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
clearInputState()
break
case "approve":
if (hasContent) {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
}
clearInputState()
break
case "reject":
if (hasContent) {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "noButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "noButtonClicked",
}),
)
}
clearInputState()
break
case "proceed":
if (hasContent) {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await TaskServiceClient.askResponse(
await sendButtonAskResponseOptimistically(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
}
clearInputState()
break
case "new_task":
@@ -341,21 +398,28 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
break
}
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
resetAutoScroll()
},
[
clineAsk,
lastMessage,
messages,
activeQuote,
clearInputState,
handleSendMessage,
startNewTask,
chatState,
backgroundCommandRunning,
setInputValue,
setActiveQuote,
setSelectedImages,
setSelectedFiles,
sendingDisabled,
setSendingDisabled,
enableButtons,
setEnableButtons,
addOptimisticUserMessage,
resetAutoScroll,
],
)
@@ -365,6 +429,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
}, [startNewTask])
return {
addOptimisticUserMessage,
handleSendMessage,
executeButtonAction,
handleTaskCloseButtonClick,
@@ -55,6 +55,7 @@ export interface ChatState {
* Message handlers interface
*/
export interface MessageHandlers {
addOptimisticUserMessage: (text: string, images?: string[], files?: string[]) => () => void
executeButtonAction: (action: ButtonActionType, text?: string, images?: string[], files?: string[]) => Promise<void>
handleSendMessage: (text: string, images: string[], files: string[]) => Promise<void>
handleTaskCloseButtonClick: () => void