mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e487126de7 |
@@ -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"
|
||||
|
||||
+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}
|
||||
|
||||
+162
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user