fix(desktop): keep the composer's text and Use through a one-key Send's verdict wait

- Send a tray suggestion with holdPromptForSend, which leaves the box as it
  is and syncs the draft to it, so text typed while the plugin's verdict was
  pending is not cleared and a remounted composer does not bring back an
  already sent prompt; typed sends still clear the box
- Put a failed one-key Send's prompt back only into a box left untouched and
  empty
- Keep a suggestion used during the verdict wait as used when the Send's
  captured ref lands, so emptying the box still sends its clear
This commit is contained in:
Harrison
2026-10-19 10:26:07 -07:00
parent 9f4ddfc085
commit a93eb70ec8
8 changed files with 290 additions and 25 deletions
+14 -10
View File
@@ -60,7 +60,7 @@ import { checkForUpdateAndNotify, useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useComposerSuggestions } from "@/hooks/use-composer-suggestions";
import { usePendingAttachments } from "@/hooks/use-pending-attachments";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import { type RestorePrompt, usePromptDraft } from "@/hooks/use-prompt-draft";
import { useSessionAgents } from "@/hooks/use-session-agents";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
@@ -1210,6 +1210,7 @@ function ChatThreadPane({
}, [onThreadStarted, sessionId, threadId]);
const {
clearPromptForSend,
holdPromptForSend,
promptDraft,
setPromptInput,
handlePromptInputChange,
@@ -1912,7 +1913,7 @@ function ChatThreadPane({
);
const handleSend = useCallback(
async (prompt: string) => {
async (prompt: string, takeDraft: () => RestorePrompt) => {
const trimmed = prompt.trim();
if (!trimmed && pendingAttachments.length === 0) {
return;
@@ -1921,10 +1922,7 @@ function ChatThreadPane({
return;
}
onThreadStarted?.(threadId);
// Also clear the injected draft: the composer cleared its local copy,
// but a stale non-empty draft would repopulate the input if the
// composer remounts (e.g. a transport blip re-showing the loader).
const restorePrompt = clearPromptForSend();
const restorePrompt = takeDraft();
const toSend = [...pendingAttachments];
setPendingAttachments([]);
const promptTaken = await sendPrompt(trimmed, toSend, {
@@ -1938,7 +1936,6 @@ function ChatThreadPane({
}
},
[
clearPromptForSend,
config.repoUrl,
handleAttachFiles,
isCloudSession,
@@ -2326,9 +2323,16 @@ function ChatThreadPane({
}),
[providerCredentials, setConfig],
);
// A typed send also clears the injected draft: the composer cleared its
// local copy, but a stale non-empty draft would repopulate the input if the
// composer remounts (e.g. a transport blip re-showing the loader).
const handleSendPrompt = useCallback(
(prompt: string) => void handleSend(prompt),
[handleSend],
(prompt: string) => void handleSend(prompt, clearPromptForSend),
[clearPromptForSend, handleSend],
);
const handleSendSuggestion = useCallback(
(prompt: string) => void handleSend(prompt, holdPromptForSend),
[handleSend, holdPromptForSend],
);
const firstUserMessage = messages.find(
@@ -2391,7 +2395,7 @@ function ChatThreadPane({
waiting: pendingToolApprovals.length > 0 || pendingAskQuestions.length > 0,
messages: displayedMessages,
promptsInQueue,
onSend: handleSendPrompt,
onSend: handleSendSuggestion,
onUndoTurn: handleEditMessage,
onRemoveQueuedPrompt: removePromptInQueue,
abort,
@@ -7,7 +7,7 @@ import {
reduceSuggestionsLocal,
SUGGESTION_BREAKPOINTS,
} from "@cline/shared/browser";
import { act, type MouseEvent as ReactMouseEvent } from "react";
import { act, type MouseEvent as ReactMouseEvent, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
afterEach,
@@ -21,7 +21,7 @@ import {
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import type { ComposerSuggestionsController } from "@/hooks/composer-suggestions/types";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import { type RestorePrompt, usePromptDraft } from "@/hooks/use-prompt-draft";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import {
MODEL_SELECTION_STORAGE_KEY,
@@ -270,20 +270,41 @@ async function renderVoiceComposer({
});
}
/** A chat pane's draft and send wiring as page.tsx has it, with the real store and composer. */
function DraftComposer({
drafts,
threadId,
sendPrompt,
suggestions,
composerKey,
}: {
drafts: Map<string, string>;
threadId: string;
sendPrompt?: (prompt: string) => Promise<boolean>;
/** A new key remounts the composer inside the same pane, as the transport loader does. */
composerKey?: string;
/** Builds the tray's controller around the pane's one-key Send. */
suggestions?: (
sendSuggestion: (prompt: string) => Promise<void>,
) => ComposerSuggestionsController;
}) {
const { promptDraft, handlePromptInputChange, clearPromptForSend } =
usePromptDraft(drafts, threadId);
const {
promptDraft,
handlePromptInputChange,
clearPromptForSend,
holdPromptForSend,
} = usePromptDraft(drafts, threadId);
const send = async (prompt: string, takeDraft: () => RestorePrompt) => {
const restorePrompt = takeDraft();
if (sendPrompt && !(await sendPrompt(prompt))) restorePrompt(prompt);
};
const [controller] = useState(() =>
suggestions?.((prompt) => send(prompt, holdPromptForSend)),
);
return (
<WorkspaceProvider value={workspaceValue}>
<ChatInputBar
key={composerKey}
environmentId="local"
attachments={[]}
gitBranch="main"
@@ -303,10 +324,7 @@ function DraftComposer({
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onRemovePromptInQueue={vi.fn()}
onSend={async (prompt) => {
const restorePrompt = clearPromptForSend();
if (sendPrompt && !(await sendPrompt(prompt))) restorePrompt(prompt);
}}
onSend={(prompt) => send(prompt, clearPromptForSend)}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
promptDraft={promptDraft}
@@ -314,6 +332,7 @@ function DraftComposer({
provider="cline"
reasoningEffort="low"
status="idle"
suggestions={controller}
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
@@ -3431,4 +3450,140 @@ describe("ChatInputBar suggestion tray", () => {
onSend.mock.invocationCallOrder[0] ?? 0,
);
});
describe("one-key Send", () => {
let drafts: Map<string, string>;
beforeEach(() => {
drafts = new Map();
});
/** A pane whose tray Send submits the highlighted prompt once the plugin's verdict, `verdict()`, lands. */
function renderPane(
sendPrompt: (prompt: string) => Promise<boolean>,
{
verdict = async () => undefined,
composerKey,
}: { verdict?: () => Promise<void>; composerKey?: string } = {},
) {
return act(async () => {
root.render(
<DraftComposer
composerKey={composerKey}
drafts={drafts}
sendPrompt={sendPrompt}
suggestions={(sendSuggestion) => ({
...stubSuggestions(),
send: async (view) => {
await verdict();
await sendSuggestion(view.highlighted.prompt);
},
})}
threadId="A"
/>,
);
});
}
/** Shift+Ctrl+1. */
function pressSend() {
return act(async () => {
document.body.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "Digit1",
key: "!",
ctrlKey: true,
shiftKey: true,
}),
);
});
}
async function typeIntoBox(value: string) {
const textarea = container.querySelector("textarea");
const setValue = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
await act(async () => {
setValue?.call(textarea, value);
textarea?.dispatchEvent(new Event("input", { bubbles: true }));
});
}
it("leaves text typed during the verdict wait in the box", async () => {
const verdict = deferred<void>();
const sendPrompt = vi.fn(async (_prompt: string) => true);
await renderPane(sendPrompt, { verdict: () => verdict.promise });
await pressSend();
await typeIntoBox("also update the changelog");
await act(async () => verdict.resolve());
expect(sendPrompt).toHaveBeenCalledWith(suggestion.prompt);
expect(container.querySelector("textarea")?.value).toBe(
"also update the changelog",
);
expect(drafts.get("A")).toBe("also update the changelog");
});
it("puts a failed send's prompt back into the box it left empty", async () => {
const verdict = deferred<void>();
await renderPane(async () => false, { verdict: () => verdict.promise });
await pressSend();
await act(async () => verdict.resolve());
expect(container.querySelector("textarea")?.value).toBe(
suggestion.prompt,
);
});
describe("when the composer remounts after the send", () => {
function failOnceThenSend() {
return vi
.fn<(prompt: string) => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
}
it("does not bring back a restored prompt the user cleared and then sent", async () => {
const sendPrompt = failOnceThenSend();
await renderPane(sendPrompt);
await pressSend();
expect(container.querySelector("textarea")?.value).toBe(
suggestion.prompt,
);
await typeIntoBox("");
await pressSend();
expect(sendPrompt).toHaveBeenCalledTimes(2);
await renderPane(sendPrompt, { composerKey: "remounted" });
expect(container.querySelector("textarea")?.value).toBe("");
});
it("shows the text typed during the verdict wait, not the sent prompt", async () => {
const sendPrompt = failOnceThenSend();
const secondVerdict = deferred<void>();
const verdicts = [Promise.resolve(), secondVerdict.promise];
await renderPane(sendPrompt, {
verdict: () => verdicts.shift() ?? Promise.resolve(),
});
await pressSend();
await typeIntoBox("");
await pressSend();
await typeIntoBox("also update the changelog");
await act(async () => secondVerdict.resolve());
expect(sendPrompt).toHaveBeenCalledTimes(2);
await renderPane(sendPrompt, { composerKey: "remounted" });
expect(container.querySelector("textarea")?.value).toBe(
"also update the changelog",
);
});
});
});
});
@@ -45,6 +45,7 @@ export type ComposerSuggestionsOptions = {
waiting: boolean;
messages: ChatMessage[];
promptsInQueue: readonly PromptInQueue[];
/** Submits a one-key Send's prompt. It never came from the box, which may hold text typed during the verdict wait, so the box stays as it is. */
onSend: (prompt: string) => void;
/** Forks the session from before `runCount` with `content` as the new draft. */
onUndoTurn: (
@@ -159,6 +159,31 @@ describe("useComposerSuggestions", () => {
await act(async () => verdict.resolve({ outcome: "ok" }));
});
it("keeps a suggestion used while a one-key Send waited, so emptying the box still sends clear", async () => {
const verdict = deferred<unknown>();
scriptHub({
states: READY_R1,
reply: (_sessionId, action) =>
action.verb === "send" ? verdict.promise : undefined,
});
const tray = await mountTray();
await settle(400);
await press(tray.composer(), SEND_KEYS);
await press(tray.composer(), { code: "ArrowDown", key: "ArrowDown" });
await press(tray.composer(), USE_KEYS);
await act(async () => verdict.resolve({ outcome: "ok" }));
await settle();
expect(tray.controller().local.sent?.itemId).toBe("verify");
expect(tray.controller().local.used?.itemId).toBe("review");
await typeInto(tray.composer(), "");
expect(invokedActions("clear").map(({ action }) => action)).toEqual([
{ verb: "clear", revision: "r1", itemId: "review" },
]);
});
it("sends no clear for a refused Use whose box was edited before the refusal landed", async () => {
const useReply = deferred<unknown>();
scriptHub({
@@ -125,6 +125,51 @@ describe("usePromptDraft", () => {
expect(drafts.has("new-session")).toBe(false);
});
it("holds a box the send did not come from as it is, and never restores over its text", async () => {
await navigate();
act(() => current.handlePromptInputChange("Typed during the wait"));
const { version } = current.promptDraft;
let restore!: (value: string) => boolean;
act(() => {
restore = current.holdPromptForSend();
});
expect(current.promptDraft).toEqual({
version,
value: "Typed during the wait",
});
expect(current.promptInputRef.current).toBe("Typed during the wait");
act(() => expect(restore("Suggested prompt")).toBe(false));
expect(drafts.get("new-session")).toBe("Typed during the wait");
});
it("drops an injected draft the user cleared when a held send goes out", async () => {
await navigate();
act(() => current.setPromptInput("Restored prompt"));
act(() => current.handlePromptInputChange(""));
const { version } = current.promptDraft;
act(() => {
current.holdPromptForSend();
});
expect(current.promptDraft).toEqual({ version, value: "" });
});
it("restores a failed held send only into the box it left untouched and empty", async () => {
await navigate();
let untouched!: (value: string) => boolean;
let edited!: (value: string) => boolean;
act(() => {
edited = current.holdPromptForSend();
current.handlePromptInputChange("Changed my mind");
current.handlePromptInputChange("");
untouched = current.holdPromptForSend();
});
act(() => expect(edited("Suggested prompt")).toBe(false));
expect(drafts.has("new-session")).toBe(false);
act(() => expect(untouched("Suggested prompt")).toBe(true));
expect(current.promptDraft.value).toBe("Suggested prompt");
expect(drafts.get("new-session")).toBe("Suggested prompt");
});
it("does not let an earlier send's failure replace a later send's draft", async () => {
await navigate();
let first!: ReturnType<typeof pendingSend>;
@@ -1,5 +1,8 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/** Puts a failed send's prompt back, but only into a box that is empty and untouched since the send. */
export type RestorePrompt = (value: string) => boolean;
/**
* Keeps drafts outside the keyed chat pane so navigation can safely unmount it.
* Keystrokes only update the ref/cache; external edits also update the composer.
@@ -45,10 +48,9 @@ export function usePromptDraft(drafts: Map<string, string>, threadId: string) {
},
[handlePromptInputChange],
);
const clearPromptForSend = useCallback(() => {
setPromptInput("");
const restorePromptIfUntouched = useCallback((): RestorePrompt => {
const revision = revisionRef.current;
return (value: string): boolean => {
return (value) => {
if (
!mountedRef.current ||
revisionRef.current !== revision ||
@@ -60,6 +62,25 @@ export function usePromptDraft(drafts: Map<string, string>, threadId: string) {
return true;
};
}, [setPromptInput]);
/**
* For a send whose prompt did not come from the box: leaves the box as it is.
* The draft takes the box's text under the same version, so the composer does
* not re-apply it, but a remounted composer starts from that text and not from
* an older injected draft, which may be a prompt already sent.
*/
const holdPromptForSend = useCallback((): RestorePrompt => {
const held = promptInputRef.current;
if (mountedRef.current) {
setPromptDraft((prev) =>
prev.value === held ? prev : { version: prev.version, value: held },
);
}
return restorePromptIfUntouched();
}, [restorePromptIfUntouched]);
const clearPromptForSend = useCallback((): RestorePrompt => {
setPromptInput("");
return restorePromptIfUntouched();
}, [restorePromptIfUntouched, setPromptInput]);
return {
promptInputRef,
@@ -67,5 +88,6 @@ export function usePromptDraft(drafts: Map<string, string>, threadId: string) {
setPromptInput,
handlePromptInputChange,
clearPromptForSend,
holdPromptForSend,
};
}
@@ -90,6 +90,16 @@ describe("sent line", () => {
expect(local.used).toBeUndefined();
expect(local.sent).toMatchObject({ itemId: "commit", revision: "r2" });
});
it("leaves a suggestion used during a one-key Send's verdict wait in place", () => {
const local = readyOnScreen(
{ type: "used", itemId: "commit" },
{ type: "sent", ref: verifyRef },
);
expect(local.sent).toEqual(verifyRef);
expect(local.used).toMatchObject({ itemId: "commit", revision: "r2" });
});
});
describe("ordering in the reducer", () => {
@@ -252,10 +252,13 @@ export function reduceSuggestionsLocal(
}
case "draft_cleared":
return local.used ? { ...local, used: undefined } : local;
case "sent": {
const sent = event.ref ?? local.used;
return sent ? { ...local, sent, used: undefined } : local;
}
case "sent":
// A one-key Send needs an empty box, so a `used` here was picked
// during its verdict wait and is still in the box.
if (event.ref) return { ...local, sent: event.ref };
return local.used
? { ...local, sent: local.used, used: undefined }
: local;
case "unsent":
return local.sent
? { ...local, used: local.sent, sent: undefined }