Adding a state machine to chat

This commit is contained in:
Cline Evaluation
2025-06-26 23:32:13 -06:00
parent ca984609ca
commit 3a1e6d2281
5 changed files with 395 additions and 296 deletions
+88 -296
View File
@@ -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" }
}
+79
View File
@@ -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 })
},
},
}
}