mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Adding a state machine to chat
This commit is contained in:
@@ -1,17 +1,10 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
|
||||
import { useEvent, useMount } from "react-use"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
import styled from "styled-components"
|
||||
import {
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineMessage,
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
ExtensionMessage,
|
||||
} from "@shared/ExtensionMessage"
|
||||
import { ClineApiReqInfo, ClineAsk, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import { findLast } from "@shared/array"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
@@ -37,6 +30,7 @@ import AutoApproveBar from "./auto-approve-menu/AutoApproveBar"
|
||||
import { SuggestedTasks } from "../welcome/SuggestedTasks"
|
||||
import { BooleanRequest, EmptyRequest, StringRequest } from "@shared/proto/common"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/task"
|
||||
import { useChatState } from "@/hooks/chat/useChatState"
|
||||
|
||||
interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
@@ -106,6 +100,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
const shouldShowQuickWins = false // !taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD
|
||||
//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)
|
||||
|
||||
// Use the chat state machine
|
||||
const { state, context, actions } = useChatState(messages, task)
|
||||
|
||||
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
|
||||
// has to be after api_req_finished are all reduced into api_req_started messages
|
||||
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
|
||||
@@ -128,14 +126,41 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
const [activeQuote, setActiveQuote] = useState<string | null>(null)
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [sendingDisabled, setSendingDisabled] = useState(false)
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>("Approve")
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
|
||||
// Synchronize input changes with state machine
|
||||
const handleInputValueChange = useCallback(
|
||||
(value: string) => {
|
||||
setInputValue(value)
|
||||
// Notify state machine of input changes
|
||||
actions.handleInputChange(value, selectedImages, selectedFiles)
|
||||
},
|
||||
[actions, selectedImages, selectedFiles],
|
||||
)
|
||||
|
||||
const handleImagesChange = useCallback(
|
||||
(images: string[]) => {
|
||||
setSelectedImages(images)
|
||||
// Notify state machine of input changes
|
||||
actions.handleInputChange(inputValue, images, selectedFiles)
|
||||
},
|
||||
[actions, inputValue, selectedFiles],
|
||||
)
|
||||
|
||||
const handleFilesChange = useCallback(
|
||||
(files: string[]) => {
|
||||
setSelectedFiles(files)
|
||||
// Notify state machine of input changes
|
||||
actions.handleInputChange(inputValue, selectedImages, files)
|
||||
},
|
||||
[actions, inputValue, selectedImages],
|
||||
)
|
||||
|
||||
const [didClickCancel, setDidClickCancel] = useState(false)
|
||||
|
||||
// Extract UI state from state machine context
|
||||
const { sendingDisabled, enableButtons, primaryButtonText, secondaryButtonText } = context
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -238,176 +263,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
document.removeEventListener("copy", handleCopy)
|
||||
}
|
||||
}, [])
|
||||
useDeepCompareEffect(() => {
|
||||
// if last message is an ask, show user ask UI
|
||||
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
|
||||
// basically as long as a task is active, the conversation history will be persisted
|
||||
if (lastMessage) {
|
||||
switch (lastMessage.type) {
|
||||
case "ask":
|
||||
const isPartial = lastMessage.partial === true
|
||||
switch (lastMessage.ask) {
|
||||
case "api_req_failed":
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Retry")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "mistake_limit_reached":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed Anyways")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "auto_approval_max_req_reached":
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "followup":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "plan_mode_respond":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "tool":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
setPrimaryButtonText("Save")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
default:
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
}
|
||||
break
|
||||
case "browser_action_launch":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "command":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Run Command")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "command_output":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed While Running")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "use_mcp_server":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "completion_result":
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_task":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Resume Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
setDidClickCancel(false) // special case where we reset the cancel button state
|
||||
break
|
||||
case "resume_completed_task":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
case "new_task":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Start New Task with Context")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "condense":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Condense Conversation")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "report_bug":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Report GitHub issue")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
}
|
||||
break
|
||||
case "say":
|
||||
// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
|
||||
switch (lastMessage.say) {
|
||||
case "api_req_started":
|
||||
if (secondLastMessage?.ask === "command_output") {
|
||||
// if the last ask is a command_output, and we receive an api_req_started, then that means the command has finished and we don't need input from the user anymore (in every other case, the user has to interact with input field or buttons to continue, which does the following automatically)
|
||||
setInputValue("")
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
}
|
||||
break
|
||||
case "task":
|
||||
case "error":
|
||||
case "api_req_finished":
|
||||
case "text":
|
||||
case "browser_action":
|
||||
case "browser_action_result":
|
||||
case "browser_action_launch":
|
||||
case "command":
|
||||
case "use_mcp_server":
|
||||
case "command_output":
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
case "completion_result":
|
||||
case "tool":
|
||||
case "load_mcp_documentation":
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// this would get called after sending the first message, so we have to watch messages.length instead
|
||||
// No messages, so user has to submit a task
|
||||
// setTextAreaDisabled(false)
|
||||
// setClineAsk(undefined)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
}
|
||||
}, [lastMessage, secondLastMessage])
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedRows({})
|
||||
@@ -452,48 +307,22 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
if (messages.length === 0) {
|
||||
await TaskServiceClient.newTask(NewTaskRequest.create({ text: messageToSend, images, files }))
|
||||
} else if (clineAsk) {
|
||||
switch (clineAsk) {
|
||||
case "followup":
|
||||
case "plan_mode_respond":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "command": // user can provide feedback to a tool or command use
|
||||
case "command_output": // user can send input to command stdin
|
||||
case "use_mcp_server":
|
||||
case "completion_result": // if this happens then the user has feedback for the completion result
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
case "mistake_limit_reached":
|
||||
case "new_task": // user can provide feedback or reject the new task suggestion
|
||||
case "condense":
|
||||
case "report_bug":
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
break
|
||||
// there is no other case that a textfield should be enabled
|
||||
}
|
||||
}
|
||||
setInputValue("")
|
||||
|
||||
// Use state machine action to handle sending
|
||||
// The state machine will determine whether to create a new task or send a message
|
||||
// based on the current state and will handle the API calls
|
||||
actions.handleInputChange(messageToSend, images, files)
|
||||
actions.handleSend()
|
||||
|
||||
// Clear local state after sending
|
||||
handleInputValueChange("")
|
||||
setActiveQuote(null) // Clear quote when sending message
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
handleImagesChange([])
|
||||
handleFilesChange([])
|
||||
disableAutoScrollRef.current = false
|
||||
}
|
||||
},
|
||||
[messages.length, clineAsk, activeQuote],
|
||||
[activeQuote, actions],
|
||||
)
|
||||
|
||||
const startNewTask = useCallback(async () => {
|
||||
@@ -506,39 +335,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
*/
|
||||
const handlePrimaryButtonClick = useCallback(
|
||||
async (text?: string, images?: string[], files?: string[]) => {
|
||||
const trimmedInput = text?.trim()
|
||||
// For special cases that need direct handling
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
case "resume_task":
|
||||
case "mistake_limit_reached":
|
||||
case "auto_approval_max_req_reached":
|
||||
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
files: files,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setActiveQuote(null) // Clear quote when using primary button
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
break
|
||||
case "completion_result":
|
||||
case "resume_completed_task":
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
@@ -564,14 +362,25 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
console.error(err),
|
||||
)
|
||||
break
|
||||
default:
|
||||
// Use state machine action for standard cases
|
||||
const trimmedInput = text?.trim()
|
||||
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
// Update input state before sending
|
||||
actions.handleInputChange(trimmedInput || "", images || [], files || [])
|
||||
}
|
||||
actions.handlePrimaryButton(trimmedInput)
|
||||
|
||||
// Clear input state after sending
|
||||
handleInputValueChange("")
|
||||
setActiveQuote(null) // Clear quote when using primary button
|
||||
handleImagesChange([])
|
||||
handleFilesChange([])
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
disableAutoScrollRef.current = false
|
||||
},
|
||||
[clineAsk, startNewTask, lastMessage],
|
||||
[clineAsk, startNewTask, lastMessage, messages, actions],
|
||||
)
|
||||
|
||||
const handleSecondaryButtonClick = useCallback(
|
||||
@@ -583,47 +392,31 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
return
|
||||
}
|
||||
|
||||
// For special cases that need direct handling
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "mistake_limit_reached":
|
||||
case "auto_approval_max_req_reached":
|
||||
startNewTask()
|
||||
break
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
default:
|
||||
// Use state machine action for standard cases
|
||||
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "noButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
files: files,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// responds to the API with a "This operation failed" and lets it try again
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "noButtonClicked",
|
||||
}),
|
||||
)
|
||||
// Update input state before sending
|
||||
actions.handleInputChange(trimmedInput || "", images || [], files || [])
|
||||
}
|
||||
actions.handleSecondaryButton(trimmedInput)
|
||||
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
handleInputValueChange("")
|
||||
setActiveQuote(null) // Clear quote when using secondary button
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
handleImagesChange([])
|
||||
handleFilesChange([])
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
disableAutoScrollRef.current = false
|
||||
},
|
||||
[clineAsk, startNewTask, isStreaming],
|
||||
[clineAsk, startNewTask, isStreaming, actions],
|
||||
)
|
||||
|
||||
const handleTaskCloseButtonClick = useCallback(() => {
|
||||
@@ -658,13 +451,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
// Prioritize images first
|
||||
const imagesToAdd = Math.min(response.values1.length, availableSlots)
|
||||
if (imagesToAdd > 0) {
|
||||
setSelectedImages((prevImages) => [...prevImages, ...response.values1.slice(0, imagesToAdd)])
|
||||
handleImagesChange([...selectedImages, ...response.values1.slice(0, imagesToAdd)])
|
||||
}
|
||||
|
||||
// Use remaining slots for files
|
||||
const remainingSlots = availableSlots - imagesToAdd
|
||||
if (remainingSlots > 0) {
|
||||
setSelectedFiles((prevFiles) => [...prevFiles, ...response.values2.slice(0, remainingSlots)])
|
||||
handleFilesChange([...selectedFiles, ...response.values2.slice(0, remainingSlots)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,11 +489,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
const cleanup = UiServiceClient.subscribeToAddToInput(EmptyRequest.create({}), {
|
||||
onResponse: (event) => {
|
||||
if (event.value) {
|
||||
setInputValue((prevValue) => {
|
||||
const newText = event.value
|
||||
const newTextWithNewline = newText + "\n"
|
||||
return prevValue ? `${prevValue}\n${newTextWithNewline}` : newTextWithNewline
|
||||
})
|
||||
const newText = event.value
|
||||
const newTextWithNewline = newText + "\n"
|
||||
const newValue = inputValue ? `${inputValue}\n${newTextWithNewline}` : newTextWithNewline
|
||||
handleInputValueChange(newValue)
|
||||
// Add scroll to bottom after state update
|
||||
// Auto focus the input and start the cursor on a new line for easy typing
|
||||
setTimeout(() => {
|
||||
@@ -720,7 +512,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
}, [inputValue, handleInputValueChange])
|
||||
|
||||
useMount(() => {
|
||||
// NOTE: the vscode window needs to be focused for this to work
|
||||
@@ -1213,7 +1005,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages, selectedFiles)}>
|
||||
onClick={() => handlePrimaryButtonClick()}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
@@ -1225,7 +1017,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
flex: isStreaming ? 2 : 1,
|
||||
marginLeft: isStreaming ? 0 : "6px",
|
||||
}}
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages, selectedFiles)}>
|
||||
onClick={() => handleSecondaryButtonClick()}>
|
||||
{isStreaming ? "Cancel" : secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ChatEvent, ChatState } from "./chatStateTypes"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
function hasContent(event: { content?: string; images?: string[]; files?: string[] }): boolean {
|
||||
return !!(event.content?.trim() || (event.images && event.images.length > 0) || (event.files && event.files.length > 0))
|
||||
}
|
||||
|
||||
function deriveStateFromMessage(message: ClineMessage): ChatState {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "tool":
|
||||
return { type: "AWAITING_TOOL_APPROVAL", tool: { name: "unknown" }, canProvideInput: true }
|
||||
case "command":
|
||||
return { type: "AWAITING_COMMAND_APPROVAL", command: message.text || "", canProvideInput: true }
|
||||
case "command_output":
|
||||
return { type: "COMMAND_OUTPUT_STREAMING", commandId: "unknown", canSendInput: true }
|
||||
case "api_req_failed":
|
||||
return { type: "API_REQUEST_FAILED", canRetry: true, errorMessage: message.text || "Unknown error" }
|
||||
// Add other cases from the original logic
|
||||
}
|
||||
}
|
||||
if (message.partial) {
|
||||
return { type: "STREAMING_RESPONSE", partial: true }
|
||||
}
|
||||
return { type: "IDLE" }
|
||||
}
|
||||
|
||||
export function chatStateReducer(
|
||||
{ state, effects }: { state: ChatState; effects?: any[] },
|
||||
event: ChatEvent,
|
||||
): { state: ChatState; effects?: any[] } {
|
||||
switch (state.type) {
|
||||
case "NO_TASK":
|
||||
if (event.type === "INPUT_CHANGED") {
|
||||
if (hasContent(event)) {
|
||||
return {
|
||||
state: {
|
||||
type: "COMPOSING",
|
||||
content: event.content || "",
|
||||
images: event.images || [],
|
||||
files: event.files || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === "SEND_CLICKED") {
|
||||
// This case is handled by the COMPOSING state, but as a safeguard:
|
||||
return { state, effects }
|
||||
}
|
||||
break
|
||||
|
||||
case "IDLE":
|
||||
if (event.type === "INPUT_CHANGED") {
|
||||
return {
|
||||
state: hasContent(event)
|
||||
? {
|
||||
type: "COMPOSING",
|
||||
content: event.content || "",
|
||||
images: event.images || [],
|
||||
files: event.files || [],
|
||||
}
|
||||
: { type: "IDLE" },
|
||||
}
|
||||
}
|
||||
if (event.type === "MESSAGE_RECEIVED") {
|
||||
return { state: deriveStateFromMessage(event.message) }
|
||||
}
|
||||
if (event.type === "MODE_TOGGLE_CLICKED") {
|
||||
return { state: { type: "TOGGLING_MODE", previousState: state } }
|
||||
}
|
||||
break
|
||||
|
||||
case "COMPOSING":
|
||||
if (event.type === "INPUT_CHANGED") {
|
||||
if (!hasContent(event)) {
|
||||
return { state: { type: "IDLE" } }
|
||||
}
|
||||
return { state: { ...state, content: event.content || "", images: event.images || [], files: event.files || [] } }
|
||||
}
|
||||
if (event.type === "SEND_CLICKED") {
|
||||
return {
|
||||
state: { type: "SENDING_MESSAGE", content: state.content, images: state.images, files: state.files },
|
||||
effects: [{ type: "SEND_MESSAGE", content: state.content, images: state.images, files: state.files }],
|
||||
}
|
||||
}
|
||||
if (event.type === "MODE_TOGGLE_CLICKED") {
|
||||
return {
|
||||
state: { type: "TOGGLING_MODE", previousState: state },
|
||||
effects: [
|
||||
{ type: "TOGGLE_MODE_WITH_CONTENT", content: state.content, images: state.images, files: state.files },
|
||||
],
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
// TODO: Implement other state transitions
|
||||
}
|
||||
return { state, effects }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
export type ToolInfo = {
|
||||
name: string
|
||||
// Add other relevant tool details if needed
|
||||
}
|
||||
|
||||
export type ChatState =
|
||||
// No task states
|
||||
| { type: "NO_TASK" }
|
||||
| { type: "CREATING_TASK"; content: string; images: string[]; files: string[] }
|
||||
|
||||
// Normal conversation states
|
||||
| { type: "IDLE" }
|
||||
| { type: "COMPOSING"; content: string; images: string[]; files: string[] }
|
||||
| { type: "SENDING_MESSAGE"; content: string; images: string[]; files: string[] }
|
||||
| { type: "SENDING_MESSAGE" }
|
||||
| { type: "STREAMING_RESPONSE"; partial: boolean }
|
||||
|
||||
// Approval states
|
||||
| { type: "AWAITING_TOOL_APPROVAL"; tool: ToolInfo; canProvideInput: boolean }
|
||||
| { type: "AWAITING_COMMAND_APPROVAL"; command: string; canProvideInput: boolean }
|
||||
| { type: "AWAITING_BROWSER_APPROVAL"; action: string }
|
||||
| { type: "AWAITING_MCP_APPROVAL"; server: string }
|
||||
|
||||
// Special interaction states
|
||||
| { type: "COMMAND_OUTPUT_STREAMING"; commandId: string; canSendInput: true }
|
||||
| { type: "API_REQUEST_FAILED"; canRetry: boolean; errorMessage: string }
|
||||
| { type: "MISTAKE_LIMIT_REACHED" }
|
||||
| { type: "AUTO_APPROVAL_LIMIT_REACHED" }
|
||||
|
||||
// Task completion states
|
||||
| { type: "TASK_COMPLETED"; canProvideFeedback: boolean }
|
||||
| { type: "RESUMING_TASK" }
|
||||
| { type: "RESUMING_COMPLETED_TASK" }
|
||||
|
||||
// Mode switching
|
||||
| { type: "TOGGLING_MODE"; previousState: ChatState }
|
||||
|
||||
// Special actions
|
||||
| { type: "CREATING_NEW_TASK_WITH_CONTEXT" }
|
||||
| { type: "CONDENSING_CONVERSATION" }
|
||||
| { type: "REPORTING_BUG" }
|
||||
|
||||
export type ChatEvent =
|
||||
| { type: "INITIALIZE"; state: ChatState }
|
||||
| { type: "INPUT_CHANGED"; content: string; images?: string[]; files?: string[] }
|
||||
| { type: "SEND_CLICKED" }
|
||||
| { type: "MESSAGE_RECEIVED"; message: ClineMessage }
|
||||
| { type: "MODE_TOGGLE_CLICKED" }
|
||||
| { type: "PRIMARY_BUTTON_CLICKED"; input?: string }
|
||||
| { type: "SECONDARY_BUTTON_CLICKED"; input?: string }
|
||||
| { type: "SEND_FAILED"; error: Error }
|
||||
|
||||
export interface ChatStateContext {
|
||||
// UI State
|
||||
inputValue: string
|
||||
selectedImages: string[]
|
||||
selectedFiles: string[]
|
||||
activeQuote: string | null
|
||||
|
||||
// Derived State
|
||||
sendingDisabled: boolean
|
||||
enableButtons: boolean
|
||||
primaryButtonText?: string
|
||||
secondaryButtonText?: string
|
||||
placeholderText: string
|
||||
|
||||
// Behavior flags
|
||||
shouldClearInputOnSend: boolean
|
||||
shouldFocusInput: boolean
|
||||
canToggleMode: boolean
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChatState, ChatStateContext } from "./chatStateTypes"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
export function deriveContextFromState(
|
||||
state: ChatState,
|
||||
): Omit<ChatStateContext, "inputValue" | "selectedImages" | "selectedFiles" | "activeQuote"> {
|
||||
const sendingDisabled =
|
||||
state.type === "SENDING_MESSAGE" || state.type === "CREATING_TASK" || state.type === "STREAMING_RESPONSE"
|
||||
const enableButtons = state.type.startsWith("AWAITING")
|
||||
|
||||
let primaryButtonText: string | undefined
|
||||
let secondaryButtonText: string | undefined
|
||||
|
||||
if (state.type === "AWAITING_COMMAND_APPROVAL") {
|
||||
primaryButtonText = "Run Command"
|
||||
secondaryButtonText = "Reject"
|
||||
} else if (state.type === "AWAITING_TOOL_APPROVAL") {
|
||||
primaryButtonText = "Approve"
|
||||
secondaryButtonText = "Reject"
|
||||
}
|
||||
|
||||
const placeholderText = state.type === "NO_TASK" ? "Type your task here..." : "Type a message..."
|
||||
|
||||
return {
|
||||
sendingDisabled,
|
||||
enableButtons,
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
placeholderText,
|
||||
shouldClearInputOnSend: true, // Default, can be customized per state
|
||||
shouldFocusInput: true, // Default
|
||||
canToggleMode: state.type !== "CREATING_TASK" && state.type !== "SENDING_MESSAGE",
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveInitialState(lastMessage?: ClineMessage): ChatState {
|
||||
if (!lastMessage) {
|
||||
return { type: "IDLE" }
|
||||
}
|
||||
if (lastMessage.type === "ask") {
|
||||
switch (lastMessage.ask) {
|
||||
case "tool":
|
||||
return { type: "AWAITING_TOOL_APPROVAL", tool: { name: "unknown" }, canProvideInput: true }
|
||||
case "command":
|
||||
return { type: "AWAITING_COMMAND_APPROVAL", command: lastMessage.text || "", canProvideInput: true }
|
||||
case "command_output":
|
||||
return { type: "COMMAND_OUTPUT_STREAMING", commandId: "unknown", canSendInput: true }
|
||||
case "api_req_failed":
|
||||
return { type: "API_REQUEST_FAILED", canRetry: true, errorMessage: lastMessage.text || "Unknown error" }
|
||||
}
|
||||
}
|
||||
if (lastMessage.partial) {
|
||||
return { type: "STREAMING_RESPONSE", partial: true }
|
||||
}
|
||||
return { type: "IDLE" }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useReducer, useEffect, useMemo, useRef } from "react"
|
||||
import { chatStateReducer } from "./chatStateMachine"
|
||||
import { deriveContextFromState, deriveInitialState } from "./chatStateUtils"
|
||||
import { ChatStateContext } from "./chatStateTypes"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/task"
|
||||
|
||||
import { ChatState, ChatEvent } from "./chatStateTypes"
|
||||
|
||||
export function useChatState(messages: ClineMessage[], task?: ClineMessage) {
|
||||
const [internalState, dispatch] = useReducer(chatStateReducer, { state: { type: "NO_TASK" }, effects: [] })
|
||||
|
||||
useEffect(() => {
|
||||
const initialState = deriveInitialState(messages[messages.length - 1])
|
||||
dispatch({ type: "INITIALIZE", state: initialState })
|
||||
}, [task?.ts])
|
||||
|
||||
const lastMessageRef = useRef<ClineMessage>()
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage && lastMessage !== lastMessageRef.current) {
|
||||
lastMessageRef.current = lastMessage
|
||||
dispatch({ type: "MESSAGE_RECEIVED", message: lastMessage })
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
const { effects } = internalState
|
||||
if (effects) {
|
||||
effects.forEach((effect: any) => {
|
||||
switch (effect.type) {
|
||||
case "CREATE_TASK":
|
||||
TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({ text: effect.content, images: effect.images, files: effect.files }),
|
||||
)
|
||||
break
|
||||
case "SEND_MESSAGE":
|
||||
TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: effect.content,
|
||||
images: effect.images,
|
||||
files: effect.files,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [internalState.effects])
|
||||
|
||||
const context: Omit<ChatStateContext, "inputValue" | "selectedImages" | "selectedFiles" | "activeQuote"> = useMemo(() => {
|
||||
return deriveContextFromState(internalState.state)
|
||||
}, [internalState.state])
|
||||
|
||||
return {
|
||||
state: internalState.state,
|
||||
context,
|
||||
dispatch: dispatch as React.Dispatch<ChatEvent>,
|
||||
actions: {
|
||||
handleInputChange: (content: string, images?: string[], files?: string[]) => {
|
||||
dispatch({ type: "INPUT_CHANGED", content, images, files })
|
||||
},
|
||||
handleSend: () => {
|
||||
dispatch({ type: "SEND_CLICKED" })
|
||||
},
|
||||
handleModeToggle: () => {
|
||||
dispatch({ type: "MODE_TOGGLE_CLICKED" })
|
||||
},
|
||||
handlePrimaryButton: (input?: string) => {
|
||||
dispatch({ type: "PRIMARY_BUTTON_CLICKED", input })
|
||||
},
|
||||
handleSecondaryButton: (input?: string) => {
|
||||
dispatch({ type: "SECONDARY_BUTTON_CLICKED", input })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user