mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
fix(vscode): remove delay when sending message (#11817)
* fix(vscode): show new chat immediately on send * fix(vscode): restore chat input after new task failure
This commit is contained in:
@@ -21,7 +21,7 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
|
||||
const sessionId = await coordinator.initTask("hello @file", ["image.png"], ["a.ts"])
|
||||
|
||||
expect(sessionId).toBe("session-123")
|
||||
expect(sessionId).toEqual(expect.any(String))
|
||||
expect(options.clearTask).toHaveBeenCalledOnce()
|
||||
expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({
|
||||
prompt: "hello @file",
|
||||
@@ -32,19 +32,32 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
cwd: "/workspace",
|
||||
mode: "act",
|
||||
})
|
||||
expect(state.task?.taskId).toBe("session-123")
|
||||
expect(options.buildStartSessionInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: "anthropic", modelId: "model", sessionId }),
|
||||
expect.objectContaining({
|
||||
prompt: "hello @file",
|
||||
images: ["image.png"],
|
||||
files: ["a.ts"],
|
||||
cwd: "/workspace",
|
||||
mode: "act",
|
||||
}),
|
||||
)
|
||||
expect(state.task?.taskId).toBe(sessionId)
|
||||
expect(options.taskHistory.updateTaskHistoryItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "session-123", task: "hello @file", modelId: "model" }),
|
||||
expect.objectContaining({ id: sessionId, task: "hello @file", modelId: "model" }),
|
||||
)
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ type: "say", say: "task", text: "hello @file" })],
|
||||
{ type: "status", payload: { sessionId: "session-123", status: "running" } },
|
||||
{ type: "status", payload: { sessionId, status: "running" } },
|
||||
)
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
expect(options.messages.appendAndEmit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
options.sessions.startNewSession.mock.invocationCallOrder[0],
|
||||
)
|
||||
expect(options.resolveContextMentions).toHaveBeenCalledWith("hello @file")
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ send: expect.any(Function) }),
|
||||
"session-123",
|
||||
sessionId,
|
||||
"resolved: hello @file",
|
||||
["image.png"],
|
||||
["a.ts"],
|
||||
@@ -72,14 +85,15 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
})
|
||||
|
||||
it("emits a plain chat error when session start fails (e.g. provider misconfigured)", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const { coordinator, options, state } = makeCoordinator()
|
||||
options.sessions.startNewSession.mockRejectedValue(new Error("No model configured for provider openai"))
|
||||
|
||||
const sessionId = await coordinator.initTask("do something")
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).not.toHaveBeenCalled()
|
||||
expect(options.messages.emitSessionEvents).toHaveBeenCalledWith(
|
||||
expect(state.task?.taskId).toEqual(expect.any(String))
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: "say",
|
||||
@@ -87,8 +101,9 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
text: expect.stringContaining("No model configured for provider openai"),
|
||||
}),
|
||||
],
|
||||
{ type: "status", payload: { sessionId: "", status: "error" } },
|
||||
{ type: "status", payload: { sessionId: state.task?.taskId, status: "error" } },
|
||||
)
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([true, false])("forwards task useAutoCondense=%s into SDK session config inputs", async (useAutoCondense) => {
|
||||
@@ -182,10 +197,10 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
getGlobalSettingsKey: vi.fn(() => input.mode ?? "act"),
|
||||
} as unknown as StateManager,
|
||||
sessions: {
|
||||
startNewSession: vi.fn().mockResolvedValue({
|
||||
startResult: { sessionId: "session-123" },
|
||||
startNewSession: vi.fn((startInput?: { config?: { sessionId?: string } }) => ({
|
||||
startResult: { sessionId: startInput?.config?.sessionId ?? "session-123" },
|
||||
sdkHost,
|
||||
}),
|
||||
})),
|
||||
fireAndForgetSend: vi.fn(),
|
||||
},
|
||||
messages: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getProviderAuthStorageId } from "@cline/core"
|
||||
import { createSessionId } from "@cline/shared"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
@@ -65,6 +66,7 @@ export class SdkTaskStartCoordinator {
|
||||
taskSettings?: Partial<Settings>,
|
||||
): Promise<string | undefined> {
|
||||
Logger.log(`[SdkController] initTask called: "${prompt?.substring(0, 50)}"`)
|
||||
let taskSessionId: string | undefined
|
||||
try {
|
||||
await this.options.clearTask()
|
||||
|
||||
@@ -93,7 +95,13 @@ export class SdkTaskStartCoordinator {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const startInput = this.options.buildStartSessionInput(config, {
|
||||
taskSessionId = config.sessionId?.trim() || createSessionId()
|
||||
const configWithSessionId = {
|
||||
...config,
|
||||
sessionId: taskSessionId,
|
||||
}
|
||||
|
||||
const startInput = this.options.buildStartSessionInput(configWithSessionId, {
|
||||
prompt: prompt,
|
||||
images,
|
||||
files,
|
||||
@@ -103,30 +111,40 @@ export class SdkTaskStartCoordinator {
|
||||
mode,
|
||||
})
|
||||
|
||||
const task = this.createAndSetTask(taskSessionId)
|
||||
this.emitInitialTaskMessage(taskSessionId, prompt ?? "")
|
||||
|
||||
const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput)
|
||||
this.createAndSetTask(startResult.sessionId)
|
||||
if (startResult.sessionId !== taskSessionId) {
|
||||
Logger.warn(
|
||||
`[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`,
|
||||
)
|
||||
task.taskId = startResult.sessionId
|
||||
taskSessionId = startResult.sessionId
|
||||
}
|
||||
|
||||
const newHistoryItem = this.options.createHistoryItemFromSession(
|
||||
startResult.sessionId,
|
||||
taskSessionId,
|
||||
prompt ?? "",
|
||||
config.modelId,
|
||||
configWithSessionId.modelId,
|
||||
cwd,
|
||||
)
|
||||
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
|
||||
|
||||
this.emitInitialTaskMessage(startResult.sessionId, prompt ?? "")
|
||||
await this.options.postStateToWebview()
|
||||
|
||||
if (prompt?.trim()) {
|
||||
Logger.log(`[SdkController] Sending prompt to session: ${startResult.sessionId}`)
|
||||
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
|
||||
const resolvedTask = await this.options.resolveContextMentions(prompt)
|
||||
this.options.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, resolvedTask, images, files)
|
||||
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Task initialized: ${startResult.sessionId}`)
|
||||
return startResult.sessionId
|
||||
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
|
||||
return taskSessionId
|
||||
} catch (error) {
|
||||
this.handleInitError(error)
|
||||
this.handleInitError(error, taskSessionId)
|
||||
await this.options.postStateToWebview().catch((postError) => {
|
||||
Logger.error("[SdkController] Failed to post state after init error:", postError)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -172,14 +190,14 @@ export class SdkTaskStartCoordinator {
|
||||
return m === "plan" ? m : "act"
|
||||
}
|
||||
|
||||
private createAndSetTask(sessionId: string): void {
|
||||
this.options.setTask(
|
||||
createTaskProxy(
|
||||
sessionId,
|
||||
(text?: string, images?: string[], files?: string[]) => this.options.onAskResponse(text, images, files),
|
||||
() => this.options.onCancelTask(),
|
||||
),
|
||||
private createAndSetTask(sessionId: string): TaskProxy {
|
||||
const task = createTaskProxy(
|
||||
sessionId,
|
||||
(text?: string, images?: string[], files?: string[]) => this.options.onAskResponse(text, images, files),
|
||||
() => this.options.onCancelTask(),
|
||||
)
|
||||
this.options.setTask(task)
|
||||
return task
|
||||
}
|
||||
|
||||
private emitInitialTaskMessage(sessionId: string, task: string): void {
|
||||
@@ -196,13 +214,13 @@ export class SdkTaskStartCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
private handleInitError(error: unknown): void {
|
||||
private handleInitError(error: unknown, sessionId?: string): void {
|
||||
const errorDetails =
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack?.substring(0, 500)}` : String(error)
|
||||
Logger.error(`[SdkController] Failed to init task: ${errorDetails}`)
|
||||
;(globalThis as Record<string, unknown>).__cline_last_init_error = errorDetails
|
||||
;(globalThis as Record<string, unknown>).__cline_last_init_error_raw = error
|
||||
this.options.messages.emitSessionEvents(
|
||||
this.options.messages.appendAndEmit(
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
@@ -212,7 +230,7 @@ export class SdkTaskStartCoordinator {
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
{ type: "status", payload: { sessionId: "", status: "error" } },
|
||||
{ type: "status", payload: { sessionId: sessionId ?? "", status: "error" } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+63
-5
@@ -43,9 +43,9 @@ import type { ChatState } from "../types/chatTypes"
|
||||
import { useMessageHandlers } from "./useMessageHandlers"
|
||||
|
||||
// Minimal ChatState stub. clineAsk/lastMessage are the only derived values the send path reads.
|
||||
function makeChatState(messages: ClineMessage[]): ChatState {
|
||||
function makeChatState(messages: ClineMessage[], overrides: Partial<ChatState> = {}): ChatState {
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
const state = {
|
||||
inputValue: "",
|
||||
setInputValue: vi.fn(),
|
||||
activeQuote: null,
|
||||
@@ -75,6 +75,7 @@ function makeChatState(messages: ClineMessage[]): ChatState {
|
||||
clearExpandedRows: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
} as unknown as ChatState
|
||||
return { ...state, ...overrides } as ChatState
|
||||
}
|
||||
|
||||
const completedConversation: ClineMessage[] = [
|
||||
@@ -84,9 +85,12 @@ const completedConversation: ClineMessage[] = [
|
||||
|
||||
describe("useMessageHandlers — send routing", () => {
|
||||
beforeEach(() => {
|
||||
newTask.mockClear()
|
||||
askResponse.mockClear()
|
||||
condense.mockClear()
|
||||
newTask.mockReset()
|
||||
newTask.mockResolvedValue(undefined)
|
||||
askResponse.mockReset()
|
||||
askResponse.mockResolvedValue(undefined)
|
||||
condense.mockReset()
|
||||
condense.mockResolvedValue(undefined)
|
||||
mockTurnState = undefined
|
||||
})
|
||||
|
||||
@@ -168,6 +172,60 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("restores pending new-task UI state when the RPC fails", async () => {
|
||||
mockTurnState = { phase: "idle", seq: 1 }
|
||||
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 chatState = makeChatState([], {
|
||||
activeQuote: "selected context",
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
setSendingDisabled,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
setEnableButtons,
|
||||
})
|
||||
const { result } = renderHook(() => useMessageHandlers([], chatState))
|
||||
newTask.mockRejectedValueOnce(error)
|
||||
|
||||
let caught: unknown
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.handleSendMessage("brand new task", ["image.png"], ["a.ts"])
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
})
|
||||
|
||||
expect(caught).toBe(error)
|
||||
expect(newTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("selected context"),
|
||||
images: ["image.png"],
|
||||
files: ["a.ts"],
|
||||
}),
|
||||
)
|
||||
expect(setInputValue).toHaveBeenNthCalledWith(1, "")
|
||||
expect(setInputValue).toHaveBeenLastCalledWith("brand new task")
|
||||
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)
|
||||
})
|
||||
|
||||
// The webview does not gate sends on provider usability: submission always
|
||||
// reaches the extension, which surfaces auth/config problems as chat errors
|
||||
// (emitClineAuthError for the Cline provider, say:"error" otherwise).
|
||||
|
||||
@@ -19,7 +19,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
setActiveQuote,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
sendingDisabled,
|
||||
setSendingDisabled,
|
||||
enableButtons,
|
||||
setEnableButtons,
|
||||
clineAsk,
|
||||
lastMessage,
|
||||
@@ -61,15 +63,36 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
let messageSent = false
|
||||
const clearSentMessageState = () => {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
}
|
||||
const restorePendingMessageState = () => {
|
||||
setInputValue(text)
|
||||
setActiveQuote(activeQuote)
|
||||
setSendingDisabled(sendingDisabled)
|
||||
setSelectedImages(images)
|
||||
setSelectedFiles(files)
|
||||
setEnableButtons(enableButtons)
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
await TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
const request = NewTaskRequest.create({
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
})
|
||||
clearSentMessageState()
|
||||
try {
|
||||
await TaskServiceClient.newTask(request)
|
||||
} catch (error) {
|
||||
restorePendingMessageState()
|
||||
throw error
|
||||
}
|
||||
messageSent = true
|
||||
} else if (clineAsk) {
|
||||
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
|
||||
@@ -147,14 +170,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear input and disable UI if message was actually sent
|
||||
// New tasks clear optimistically before the RPC; the repeated success cleanup is idempotent.
|
||||
if (messageSent) {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
clearSentMessageState()
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
@@ -170,9 +188,11 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
activeQuote,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
sendingDisabled,
|
||||
setSendingDisabled,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
enableButtons,
|
||||
setEnableButtons,
|
||||
chatState,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user