mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
desktop: preserve the prompt when the provider connection fails (#14008)
* desktop: hand the prompt back to the composer when the runtime never takes it When a send fails before the turn begins (e.g. switching to Codex and the OAuth refresh throws), the sidecar synthesizes a messages-less error result and the user turn was never appended to the session. Post-send hydration then wiped the optimistic bubble, so the prompt vanished entirely and had to be retyped. sendPrompt now resolves false when the runtime never took the prompt and retracts the optimistic user bubble; the thread pane restores the text and attachments to the composer (unless the user has typed something since). Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * desktop: merge restored attachments with ones added during the failed send Reuse handleAttachFiles (which already dedupes by name/size/mtime) instead of an either-or restore, so attachments added while the send was pending no longer drop the failed submission's attachments. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
co-authored by
Saoud Rizwan
parent
038102987e
commit
35b4cd010a
@@ -1082,6 +1082,23 @@ function ChatThreadPane({
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (prompt: string) => {
|
||||
const trimmed = prompt.trim();
|
||||
@@ -1095,9 +1112,23 @@ function ChatThreadPane({
|
||||
setPromptInput("");
|
||||
const toSend = [...pendingAttachments];
|
||||
setPendingAttachments([]);
|
||||
await sendPrompt(trimmed, toSend);
|
||||
const promptTaken = await sendPrompt(trimmed, toSend);
|
||||
// The prompt never reached the runtime (e.g. the provider connection
|
||||
// failed): hand it back so the user can fix the provider and resend
|
||||
// without retyping. Leave anything they typed meanwhile alone.
|
||||
if (!promptTaken && promptInputRef.current.trim() === "") {
|
||||
setPromptInput(trimmed);
|
||||
handleAttachFiles(toSend);
|
||||
}
|
||||
},
|
||||
[onThreadStarted, pendingAttachments, sendPrompt, setPromptInput, threadId],
|
||||
[
|
||||
handleAttachFiles,
|
||||
onThreadStarted,
|
||||
pendingAttachments,
|
||||
sendPrompt,
|
||||
setPromptInput,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
@@ -1272,23 +1303,6 @@ function ChatThreadPane({
|
||||
setPromptInput,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
|
||||
@@ -949,6 +949,68 @@ describe("useChatSession", () => {
|
||||
).toContain(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// The sidecar synthesizes a messages-less error result when the runtime
|
||||
// threw before the turn began (e.g. the Codex OAuth refresh failed on a
|
||||
// provider switch): the prompt never entered the session, so it must be
|
||||
// handed back to the composer instead of vanishing.
|
||||
{
|
||||
label: "hands the prompt back when the runtime threw before the turn",
|
||||
messages: undefined,
|
||||
expectedTaken: false,
|
||||
expectedUserMessages: 0,
|
||||
},
|
||||
// A run that failed mid-turn persisted the user turn; the prompt stays
|
||||
// in the transcript and must not be duplicated into the composer.
|
||||
{
|
||||
label: "keeps the prompt when the run failed mid-turn",
|
||||
messages: [{ role: "user", content: "Rebase the branch" }],
|
||||
expectedTaken: true,
|
||||
expectedUserMessages: 1,
|
||||
},
|
||||
])("$label", async ({ messages, expectedTaken, expectedUserMessages }) => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
finishReason: "error",
|
||||
text: "Token refresh failed: 401 - Could not validate your refresh token.",
|
||||
messages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let taken: boolean | undefined;
|
||||
await act(async () => {
|
||||
taken = await current.sendPrompt("Rebase the branch");
|
||||
});
|
||||
|
||||
expect(taken).toBe(expectedTaken);
|
||||
expect(current.status).toBe("failed");
|
||||
expect(
|
||||
current.messages.filter((message) => message.role === "user"),
|
||||
).toHaveLength(expectedUserMessages);
|
||||
expect(
|
||||
current.messages.findLast((message) => message.role === "error")?.content,
|
||||
).toContain("Token refresh failed: 401");
|
||||
});
|
||||
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
|
||||
@@ -2087,10 +2087,13 @@ export function useChatSession() {
|
||||
],
|
||||
);
|
||||
|
||||
// Resolves to false when the runtime never took the prompt (a failure
|
||||
// before dispatch, or a provider switch / OAuth refresh that threw before
|
||||
// the turn began) so the caller can hand the text back to the composer.
|
||||
const sendPrompt = useCallback(
|
||||
async (prompt: string, attachedFiles: File[] = []) => {
|
||||
async (prompt: string, attachedFiles: File[] = []): Promise<boolean> => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed && attachedFiles.length === 0) return;
|
||||
if (!trimmed && attachedFiles.length === 0) return true;
|
||||
|
||||
setError(null);
|
||||
setIsHydratingSession(false);
|
||||
@@ -2102,7 +2105,7 @@ export function useChatSession() {
|
||||
const validation = validateConfig(config);
|
||||
if (!validation.parsed) {
|
||||
setErrorState(validation.error, activeSessionId);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const parsed = validation.parsed;
|
||||
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
|
||||
@@ -2163,6 +2166,20 @@ export function useChatSession() {
|
||||
: null;
|
||||
const optimisticUserMessageId = shouldQueue ? null : makeId("user");
|
||||
const plannedSessionId = activeSessionId ?? makeId("session");
|
||||
// The prompt never reached the runtime: retract its optimistic bubble
|
||||
// so the caller can hand the text back to the composer without the
|
||||
// transcript showing it as sent.
|
||||
const withdrawPrompt = () => {
|
||||
if (optimisticUserMessageId) {
|
||||
outstandingOptimisticUserIdsRef.current.delete(
|
||||
optimisticUserMessageId,
|
||||
);
|
||||
setMessages((prev) =>
|
||||
prev.filter((message) => message.id !== optimisticUserMessageId),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (optimisticUserMessageId) {
|
||||
outstandingOptimisticUserIdsRef.current.add(optimisticUserMessageId);
|
||||
@@ -2216,7 +2233,7 @@ export function useChatSession() {
|
||||
}
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
return withdrawPrompt();
|
||||
}
|
||||
} else if (
|
||||
activeSessionId &&
|
||||
@@ -2235,7 +2252,7 @@ export function useChatSession() {
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
return withdrawPrompt();
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
@@ -2260,7 +2277,7 @@ export function useChatSession() {
|
||||
}
|
||||
setErrorState(errorMessage(err));
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
return withdrawPrompt();
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
@@ -2274,7 +2291,7 @@ export function useChatSession() {
|
||||
activeSessionId,
|
||||
);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
return withdrawPrompt();
|
||||
}
|
||||
const serializedAttachments = serializedAttachmentsResult.attachments;
|
||||
const hasAttachments =
|
||||
@@ -2317,9 +2334,10 @@ export function useChatSession() {
|
||||
}
|
||||
if (!sendTask) {
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
return withdrawPrompt();
|
||||
}
|
||||
let abortedReconcileEpoch: number | undefined;
|
||||
let promptTaken = true;
|
||||
const settleAbortedSend = () => {
|
||||
if (!abortedRef.current) return false;
|
||||
if (
|
||||
@@ -2335,7 +2353,7 @@ export function useChatSession() {
|
||||
try {
|
||||
const payload = await sendTask;
|
||||
if (payload.ok && payload.queued) {
|
||||
if (settleAbortedSend()) return;
|
||||
if (settleAbortedSend()) return true;
|
||||
if (turnEpochRef.current !== turnEpochAtDispatch) {
|
||||
// The runtime already started consuming a queued prompt
|
||||
// (chat_queued_prompt_start bumped the epoch) while this
|
||||
@@ -2346,11 +2364,11 @@ export function useChatSession() {
|
||||
// back to "running" — wedging the composer forever. The
|
||||
// stream (chat_queued_prompt_start/chat_done and
|
||||
// prompts_in_queue_state) is authoritative from here on.
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
setStatus("running");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The runtime drains the queue before it answers a blocking send,
|
||||
@@ -2366,7 +2384,7 @@ export function useChatSession() {
|
||||
|
||||
const result = payload.result as ChatApiResult | undefined;
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
if (settleAbortedSend()) return;
|
||||
if (settleAbortedSend()) return true;
|
||||
// On a failed run the runtime reports the error string in
|
||||
// result.text — it is not assistant content and must not be
|
||||
// rendered as an assistant bubble (canonical rehydration would
|
||||
@@ -2668,7 +2686,7 @@ export function useChatSession() {
|
||||
const hasQueuedFollowUps =
|
||||
Array.isArray(payload.promptsInQueue) &&
|
||||
payload.promptsInQueue.length > 0;
|
||||
if (settleAbortedSend()) return;
|
||||
if (settleAbortedSend()) return true;
|
||||
// A queued prompt that already started its turn owns the status
|
||||
// and the settled epoch from here: its start set "running", and
|
||||
// its own completion settles it. Settling this turn on top of it
|
||||
@@ -2694,6 +2712,14 @@ export function useChatSession() {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("failed");
|
||||
}
|
||||
// A run that fails mid-turn always carries `messages` (the
|
||||
// user turn is persisted). The sidecar synthesizes a
|
||||
// messages-less error result when the runtime threw before
|
||||
// the turn began — e.g. the provider switch or OAuth refresh
|
||||
// failed — so the prompt never entered the session.
|
||||
if (!result.messages) {
|
||||
promptTaken = withdrawPrompt();
|
||||
}
|
||||
} else if (result?.finishReason === "aborted") {
|
||||
if (!newerTurnOwnsStatus) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
@@ -2709,7 +2735,7 @@ export function useChatSession() {
|
||||
}
|
||||
void refreshSessionDiffSummary(activeSessionId);
|
||||
} catch (err) {
|
||||
if (settleAbortedSend()) return;
|
||||
if (settleAbortedSend()) return true;
|
||||
if (optimisticQueuedPromptId) {
|
||||
setPromptsInQueue((prev) =>
|
||||
prev.filter((item) => item.id !== optimisticQueuedPromptId),
|
||||
@@ -2735,6 +2761,7 @@ export function useChatSession() {
|
||||
finalizeSettledTurn(activeSessionId);
|
||||
}
|
||||
}
|
||||
return promptTaken;
|
||||
},
|
||||
[
|
||||
addMessage,
|
||||
|
||||
Reference in New Issue
Block a user