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:
Saoud Rizwan
2026-06-25 02:56:55 -07:00
committed by GitHub
parent 54d022b536
commit 21a0e4c4f1
4 changed files with 162 additions and 51 deletions
@@ -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" } },
)
}
@@ -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,
],