mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user