mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(desktop): refuse the tray's Send like the composer and undo the exact sent turn with its files
- Run the composer's own send checks (dictation, read-only, images the model cannot take, a missing cloud repo or model) before the tray's Send click or one-key Send, with the same toast - Find a sent suggestion's message only among turns after the one it was sent at, so Undo never forks from an earlier copy of the same prompt - Read past the [attached N files] label when matching a sent prompt in the queue or the chat, so a send that carried files can be undone - Put the files back on the composer when Undo takes back a sent prompt that is still queued - Move the attached-files label into lib/attached-files-label.ts so the label and its removal stay in step
This commit is contained in:
@@ -2379,6 +2379,8 @@ function ChatThreadPane({
|
||||
waiting: pendingToolApprovals.length > 0 || pendingAskQuestions.length > 0,
|
||||
messages: displayedMessages,
|
||||
promptsInQueue,
|
||||
pendingAttachments,
|
||||
onAttachFiles: handleAttachFiles,
|
||||
onSend: handleSendSuggestion,
|
||||
onUndoTurn: handleEditMessage,
|
||||
onRemoveQueuedPrompt: removePromptInQueue,
|
||||
|
||||
@@ -3459,23 +3459,27 @@ describe("ChatInputBar suggestion tray", () => {
|
||||
prompt = "",
|
||||
promptVersion = 0,
|
||||
onSend = vi.fn(),
|
||||
attachments = [],
|
||||
model = "test-model",
|
||||
}: {
|
||||
suggestions: ComposerSuggestionsController;
|
||||
variant?: "conversation" | "welcome";
|
||||
prompt?: string;
|
||||
promptVersion?: number;
|
||||
onSend?: (prompt: string) => void;
|
||||
attachments?: Parameters<typeof ChatInputBar>[0]["attachments"];
|
||||
model?: string;
|
||||
}) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
attachments={attachments}
|
||||
environmentId="local"
|
||||
executionTarget="local"
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
model={model}
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
@@ -3613,6 +3617,95 @@ describe("ChatInputBar suggestion tray", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the tray's Send, with the composer's reason, while the composer would refuse its own", async () => {
|
||||
const suggestions = stubSuggestions();
|
||||
const attachments = [{ id: "image", name: "photo.png", isImage: true }];
|
||||
await renderWithSuggestions({
|
||||
suggestions,
|
||||
attachments,
|
||||
model: "text-only",
|
||||
});
|
||||
await act(async () => {
|
||||
subscribeToProviderModelsMock.mock.calls.at(-1)?.[0]("cline", [
|
||||
{ id: "text-only", name: "Text only", inputModalities: ["text"] },
|
||||
]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="suggestion-legend"] button[data-verb="send"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
container.querySelector("textarea")?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "Digit1",
|
||||
key: "!",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(suggestions.send).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledTimes(2);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "This model doesn’t support image input",
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("photo.png");
|
||||
|
||||
await renderWithSuggestions({
|
||||
suggestions,
|
||||
attachments: [],
|
||||
model: "text-only",
|
||||
});
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="suggestion-legend"] button[data-verb="send"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
|
||||
expect(suggestions.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses the tray's Send while dictation is filling the box", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue(
|
||||
providerCatalog({
|
||||
providerId: "vercel-ai-gateway",
|
||||
providerName: "Vercel AI Gateway",
|
||||
modelId: "openai/gpt-realtime-whisper",
|
||||
modelName: "GPT-4o mini Transcribe",
|
||||
supportsStreaming: true,
|
||||
}),
|
||||
);
|
||||
const suggestions = stubSuggestions();
|
||||
await renderWithSuggestions({ suggestions });
|
||||
await vi.waitFor(() => {
|
||||
expect(speechInputMockState.current).not.toBeNull();
|
||||
});
|
||||
await act(async () => {
|
||||
speechInputMockState.current?.onActiveChange?.(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="suggestion-legend"] button[data-verb="send"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
|
||||
expect(suggestions.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("one-key Send", () => {
|
||||
let drafts: Map<string, string>;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -567,13 +567,23 @@ function ChatInputBarImpl({
|
||||
steeringPromptRef.current = false;
|
||||
}
|
||||
};
|
||||
const handleSend = useCallback(() => {
|
||||
if (speechInputActive || readOnly) return;
|
||||
const composerRefusesSend = useCallback(() => {
|
||||
if (speechInputActive || readOnly) return true;
|
||||
if (unsupportedDraftImageCount > 0) {
|
||||
reportUnsupportedImages();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (needsCloudRepository || needsCloudModel) return;
|
||||
return needsCloudRepository || needsCloudModel;
|
||||
}, [
|
||||
needsCloudRepository,
|
||||
needsCloudModel,
|
||||
readOnly,
|
||||
speechInputActive,
|
||||
unsupportedDraftImageCount,
|
||||
reportUnsupportedImages,
|
||||
]);
|
||||
const handleSend = useCallback(() => {
|
||||
if (composerRefusesSend()) return;
|
||||
const prompt = promptInput.trim();
|
||||
if (!prompt) {
|
||||
toast({
|
||||
@@ -586,18 +596,7 @@ function ChatInputBarImpl({
|
||||
suggestions?.submitted(prompt);
|
||||
setPromptInput("");
|
||||
onSend(prompt);
|
||||
}, [
|
||||
needsCloudRepository,
|
||||
needsCloudModel,
|
||||
readOnly,
|
||||
onSend,
|
||||
promptInput,
|
||||
setPromptInput,
|
||||
speechInputActive,
|
||||
suggestions,
|
||||
unsupportedDraftImageCount,
|
||||
reportUnsupportedImages,
|
||||
]);
|
||||
}, [composerRefusesSend, onSend, promptInput, setPromptInput, suggestions]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [transcriptionTarget, setTranscriptionTarget] =
|
||||
useState<TranscriptionModelTarget | null>(null);
|
||||
@@ -1708,6 +1707,7 @@ function ChatInputBarImpl({
|
||||
composerInputRef={promptInputRef}
|
||||
composerMenuOpen={slashOpen || mentionOpen}
|
||||
draft={promptInput}
|
||||
refusesSend={composerRefusesSend}
|
||||
setDraft={setPromptInput}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
+5
@@ -43,6 +43,7 @@ export type SuggestionTrayProps = {
|
||||
suggestions: ComposerSuggestionsController;
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
refusesSend: () => boolean;
|
||||
composerInputRef: RefObject<HTMLTextAreaElement | null>;
|
||||
/** The slash or @ menu is open over the message box; the tray steps aside. */
|
||||
composerMenuOpen: boolean;
|
||||
@@ -156,6 +157,7 @@ export function SuggestionTray({
|
||||
suggestions,
|
||||
draft,
|
||||
setDraft,
|
||||
refusesSend,
|
||||
composerInputRef,
|
||||
composerMenuOpen,
|
||||
}: SuggestionTrayProps) {
|
||||
@@ -176,6 +178,8 @@ export function SuggestionTray({
|
||||
draftRef.current = draft;
|
||||
const setDraftRef = useRef(setDraft);
|
||||
setDraftRef.current = setDraft;
|
||||
const refusesSendRef = useRef(refusesSend);
|
||||
refusesSendRef.current = refusesSend;
|
||||
const factsRef = useRef({ width, menuOpen: composerMenuOpen, dialogOpen });
|
||||
factsRef.current = { width, menuOpen: composerMenuOpen, dialogOpen };
|
||||
const movedByKeyRef = useRef(false);
|
||||
@@ -196,6 +200,7 @@ export function SuggestionTray({
|
||||
input.setSelectionRange(end, end);
|
||||
}, 0);
|
||||
},
|
||||
refusesSend: () => refusesSendRef.current(),
|
||||
}),
|
||||
[composerInputRef],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
withAttachedFilesLabel,
|
||||
withoutAttachedFilesLabel,
|
||||
} from "@/lib/attached-files-label";
|
||||
import { findSentMessage, samePrompt, snapshotAtSend } from "./sent-prompt";
|
||||
import { userMessage } from "./test-support/fixtures";
|
||||
|
||||
const PROMPT = "Run the tests and fix what fails";
|
||||
|
||||
const earlierTurns = [
|
||||
userMessage("m-earlier", PROMPT, 1),
|
||||
userMessage("m-between", "Refactor the parser", 2),
|
||||
];
|
||||
const sentAfter = (messages: typeof earlierTurns) =>
|
||||
snapshotAtSend({ messages, pendingAttachments: [] }).runCount;
|
||||
|
||||
describe("findSentMessage", () => {
|
||||
it("finds the prompt's own message after the run count it was sent at, never an earlier copy", () => {
|
||||
const messages = [
|
||||
...earlierTurns,
|
||||
userMessage("m-sent", withAttachedFilesLabel(PROMPT, 1), 3),
|
||||
];
|
||||
|
||||
expect(findSentMessage(messages, PROMPT, sentAfter(earlierTurns))).toEqual({
|
||||
messageId: "m-sent",
|
||||
runCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds nothing while only an earlier copy exists", () => {
|
||||
expect(
|
||||
findSentMessage(earlierTurns, PROMPT, sentAfter(earlierTurns)),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotAtSend", () => {
|
||||
it("takes the newest user turn's run count, or 0 for an empty chat, and the files going with the send", () => {
|
||||
const notes = new File(["hello"], "notes.txt");
|
||||
expect(snapshotAtSend({ messages: [], pendingAttachments: [] })).toEqual({
|
||||
runCount: 0,
|
||||
attachments: [],
|
||||
});
|
||||
expect(
|
||||
snapshotAtSend({ messages: earlierTurns, pendingAttachments: [notes] }),
|
||||
).toEqual({ runCount: 2, attachments: [notes] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePrompt", () => {
|
||||
it("reads past the attached-files label the desktop adds to a sent prompt", () => {
|
||||
expect(samePrompt(withAttachedFilesLabel(PROMPT, 1), PROMPT)).toBe(true);
|
||||
expect(samePrompt(withAttachedFilesLabel(PROMPT, 2), PROMPT)).toBe(true);
|
||||
expect(samePrompt(withAttachedFilesLabel("", 2), "")).toBe(true);
|
||||
expect(samePrompt(withAttachedFilesLabel(PROMPT, 1), "Run the tests")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attached-files label", () => {
|
||||
it("comes off exactly as it went on", () => {
|
||||
for (const prompt of ["", PROMPT, "First line\n\nSecond line"]) {
|
||||
for (const fileCount of [0, 1, 2]) {
|
||||
expect(
|
||||
withoutAttachedFilesLabel(withAttachedFilesLabel(prompt, fileCount)),
|
||||
).toBe(prompt);
|
||||
}
|
||||
}
|
||||
expect(withAttachedFilesLabel(PROMPT, 2)).toBe(
|
||||
`${PROMPT}\n\n[attached 2 files]`,
|
||||
);
|
||||
expect(withAttachedFilesLabel("", 1)).toBe("[attached 1 file]");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared/browser";
|
||||
import { buildUserRunCountMap } from "@/components/views/chat/messages/group-messages";
|
||||
import { withoutAttachedFilesLabel } from "@/lib/attached-files-label";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import type { ComposerSuggestionsOptions } from "./types";
|
||||
|
||||
/** A prompt taken back from the queue goes in front of anything typed since, never over it. */
|
||||
export function takenBackDraft(prompt: string, draft: string): string {
|
||||
@@ -10,21 +12,42 @@ export function takenBackDraft(prompt: string, draft: string): string {
|
||||
}
|
||||
|
||||
export function samePrompt(text: string, prompt: string): boolean {
|
||||
return formatDisplayUserInput(text.trim()).trim() === prompt.trim();
|
||||
return (
|
||||
formatDisplayUserInput(withoutAttachedFilesLabel(text.trim())).trim() ===
|
||||
prompt.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** The last user message showing `prompt`, with the run count a fork from before it needs. */
|
||||
/** Taken when a suggestion is sent: its message will follow `runCount`, and `attachments` went with it. */
|
||||
export type SendSnapshot = { runCount: number; attachments: readonly File[] };
|
||||
|
||||
export function snapshotAtSend({
|
||||
messages,
|
||||
pendingAttachments,
|
||||
}: Pick<
|
||||
ComposerSuggestionsOptions,
|
||||
"messages" | "pendingAttachments"
|
||||
>): SendSnapshot {
|
||||
return {
|
||||
runCount: Math.max(0, ...buildUserRunCountMap(messages).values()),
|
||||
attachments: pendingAttachments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a prompt sent at `afterRunCount` became, with the run count a
|
||||
* fork from before it needs. Only later turns count, so a copy sent earlier
|
||||
* is never taken for it.
|
||||
*/
|
||||
export function findSentMessage(
|
||||
messages: ChatMessage[],
|
||||
prompt: string,
|
||||
afterRunCount: number,
|
||||
): { messageId: string; runCount: number } | undefined {
|
||||
const runCounts = buildUserRunCountMap(messages);
|
||||
const message = messages.findLast(
|
||||
(candidate) =>
|
||||
candidate.role === "user" && samePrompt(candidate.content, prompt),
|
||||
);
|
||||
const runCount = message ? runCounts.get(message) : undefined;
|
||||
return message && runCount !== undefined
|
||||
? { messageId: message.id, runCount }
|
||||
: undefined;
|
||||
for (const [message, runCount] of buildUserRunCountMap(messages)) {
|
||||
if (runCount > afterRunCount && samePrompt(message.content, prompt)) {
|
||||
return { messageId: message.id, runCount };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export function defaultOptions(
|
||||
waiting: false,
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
pendingAttachments: [],
|
||||
onAttachFiles: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onUndoTurn: vi.fn(async () => undefined),
|
||||
onRemoveQueuedPrompt: vi.fn(async (promptId: string) =>
|
||||
@@ -69,6 +71,7 @@ function Harness({
|
||||
composerInputRef={composerInputRef}
|
||||
composerMenuOpen={false}
|
||||
draft={draft}
|
||||
refusesSend={() => false}
|
||||
setDraft={setDraft}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SuggestionComposer = {
|
||||
draft(): string;
|
||||
setDraft(value: string): void;
|
||||
focus(): void;
|
||||
refusesSend(): boolean;
|
||||
};
|
||||
|
||||
/** What the tray's view depends on that only the composer knows. */
|
||||
@@ -45,6 +46,10 @@ export type ComposerSuggestionsOptions = {
|
||||
waiting: boolean;
|
||||
messages: ChatMessage[];
|
||||
promptsInQueue: readonly PromptInQueue[];
|
||||
/** The files the composer's next send carries. */
|
||||
pendingAttachments: readonly File[];
|
||||
/** Puts files back on the composer, as a send that never left does. */
|
||||
onAttachFiles: (files: File[]) => void;
|
||||
/** 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. */
|
||||
|
||||
@@ -197,6 +197,7 @@ describe("useSuggestionTrayKeys", () => {
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(tray.options().onRemoveQueuedPrompt).toHaveBeenCalledWith("q1");
|
||||
expect(tray.composer().value).toBe(SENT_PROMPT);
|
||||
expect(tray.options().onAttachFiles).not.toHaveBeenCalled();
|
||||
expect(actions("undo")).toEqual([]);
|
||||
expect(tray.options().onUndoTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -273,6 +274,85 @@ describe("useSuggestionTrayKeys", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Ctrl+3 forks from a sent prompt that went with a file, never an earlier copy", async () => {
|
||||
const onUndoTurn = vi.fn(async () => undefined);
|
||||
const abort = vi.fn(async () => undefined);
|
||||
const earlierTurns = [
|
||||
userMessage("m-earlier", SENT_PROMPT, 1),
|
||||
userMessage("m-between", "Now add a changelog entry", 2),
|
||||
];
|
||||
const tray = await mountReady({
|
||||
abort,
|
||||
onUndoTurn,
|
||||
messages: earlierTurns,
|
||||
});
|
||||
await sendFirstSuggestion(tray);
|
||||
await tray.update({
|
||||
status: "running",
|
||||
messages: [
|
||||
...earlierTurns,
|
||||
userMessage("m-sent", `${SENT_PROMPT}\n\n[attached 1 file]`, 3),
|
||||
],
|
||||
});
|
||||
|
||||
await press(tray.composer(), UNDO);
|
||||
await tray.update({ status: "cancelled" });
|
||||
await settle(100);
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
expect(onUndoTurn).toHaveBeenCalledTimes(1);
|
||||
expect(onUndoTurn).toHaveBeenCalledWith("m-sent", SENT_PROMPT, 3);
|
||||
});
|
||||
|
||||
it("Ctrl+3 is inert while only an earlier copy of the sent prompt exists", async () => {
|
||||
const onUndoTurn = vi.fn(async () => undefined);
|
||||
const abort = vi.fn(async () => undefined);
|
||||
const tray = await mountReady({
|
||||
abort,
|
||||
onUndoTurn,
|
||||
messages: [
|
||||
userMessage("m-earlier", SENT_PROMPT, 1),
|
||||
userMessage("m-between", "Now add a changelog entry", 2),
|
||||
],
|
||||
});
|
||||
await sendFirstSuggestion(tray);
|
||||
await tray.update({ status: "running" });
|
||||
|
||||
const event = await press(tray.composer(), UNDO);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-undo"]'),
|
||||
).toBeNull();
|
||||
expect(abort).not.toHaveBeenCalled();
|
||||
expect(actions("undo")).toEqual([]);
|
||||
expect(onUndoTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Ctrl+3 takes back a queued sent prompt that went with a file, and puts the file back", async () => {
|
||||
const notes = new File(["hello"], "notes.txt");
|
||||
const tray = await mountReady({ pendingAttachments: [notes] });
|
||||
await sendFirstSuggestion(tray);
|
||||
await tray.update({
|
||||
pendingAttachments: [],
|
||||
promptsInQueue: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: `${SENT_PROMPT}\n\n[attached 1 file]`,
|
||||
steer: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await press(tray.composer(), UNDO);
|
||||
|
||||
expect(tray.options().onRemoveQueuedPrompt).toHaveBeenCalledWith("q1");
|
||||
expect(tray.composer().value).toBe(SENT_PROMPT);
|
||||
expect(tray.options().onAttachFiles).toHaveBeenCalledExactlyOnceWith([
|
||||
notes,
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes a queued sent prompt back once when Undo is pressed again while the removal is in flight", async () => {
|
||||
const queued = { id: "q1", prompt: SENT_PROMPT, steer: false };
|
||||
const removal = deferred<typeof queued | undefined>();
|
||||
|
||||
@@ -77,6 +77,7 @@ export function runSuggestionVerb(
|
||||
controller.use(view.highlighted, input, composer);
|
||||
return true;
|
||||
case "send":
|
||||
if (view.canSend && composer.refusesSend()) return true;
|
||||
void controller.send(view, input);
|
||||
return true;
|
||||
case "skip":
|
||||
|
||||
@@ -8,7 +8,12 @@ import { type RefObject, useCallback, useRef } from "react";
|
||||
import { BUSY_STATUSES } from "@/hooks/use-chat-session";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { findSentMessage, samePrompt, takenBackDraft } from "./sent-prompt";
|
||||
import {
|
||||
findSentMessage,
|
||||
type SendSnapshot,
|
||||
samePrompt,
|
||||
takenBackDraft,
|
||||
} from "./sent-prompt";
|
||||
import { delay } from "./timing";
|
||||
import type {
|
||||
ComposerSuggestionsOptions,
|
||||
@@ -31,6 +36,7 @@ export function useUndoSent({
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
sendSnapshotRef,
|
||||
}: {
|
||||
status: ChatSessionStatus;
|
||||
onUndoTurn: ComposerSuggestionsOptions["onUndoTurn"];
|
||||
@@ -38,6 +44,7 @@ export function useUndoSent({
|
||||
localRef: RefObject<SuggestionsLocal>;
|
||||
latest: RefObject<ComposerSuggestionsOptions>;
|
||||
dispatch: SuggestionsDispatch;
|
||||
sendSnapshotRef: RefObject<SendSnapshot>;
|
||||
}) {
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
@@ -47,38 +54,39 @@ export function useUndoSent({
|
||||
const undoInFlightRef = useRef(false);
|
||||
|
||||
/**
|
||||
* A sent prompt the queue still holds comes back into the box with no verb,
|
||||
* since no turn ran. Otherwise, including when the runtime started it before
|
||||
* the removal landed, the turn is forked away from before the sent message,
|
||||
* after stopping a run the fork would refuse.
|
||||
* A sent prompt the queue still holds comes back into the box with the files
|
||||
* that went with it and no verb, since no turn ran. Otherwise, including when
|
||||
* the runtime started it before the removal landed, the turn is forked away
|
||||
* from before the sent message, after stopping a run the fork would refuse.
|
||||
*/
|
||||
const undoSent = useCallback(
|
||||
async (composer: SuggestionComposer) => {
|
||||
const sent = localRef.current.sent;
|
||||
if (!sent || undoInFlightRef.current) return;
|
||||
undoInFlightRef.current = true;
|
||||
const { abort, onRemoveQueuedPrompt, promptsInQueue } = latest.current;
|
||||
const { abort, onAttachFiles, onRemoveQueuedPrompt, promptsInQueue } =
|
||||
latest.current;
|
||||
try {
|
||||
const queuedPrompt = promptsInQueue.find((item) =>
|
||||
samePrompt(item.prompt, sent.prompt),
|
||||
);
|
||||
/** A prompt still queued has no message of its own, so a match now is an earlier copy. */
|
||||
let earlierCopyId: string | undefined;
|
||||
if (queuedPrompt) {
|
||||
earlierCopyId = findSentMessage(
|
||||
latest.current.messages,
|
||||
sent.prompt,
|
||||
)?.messageId;
|
||||
if (await onRemoveQueuedPrompt(queuedPrompt.id)) {
|
||||
composer.setDraft(takenBackDraft(sent.prompt, composer.draft()));
|
||||
const { attachments } = sendSnapshotRef.current;
|
||||
if (attachments.length > 0) onAttachFiles([...attachments]);
|
||||
composer.focus();
|
||||
dispatch({ type: "unsent" });
|
||||
return;
|
||||
}
|
||||
if (localRef.current.sent !== sent) return;
|
||||
}
|
||||
const target = findSentMessage(latest.current.messages, sent.prompt);
|
||||
if (!target || target.messageId === earlierCopyId) {
|
||||
const target = findSentMessage(
|
||||
latest.current.messages,
|
||||
sent.prompt,
|
||||
sendSnapshotRef.current.runCount,
|
||||
);
|
||||
if (!target) {
|
||||
if (queuedPrompt) toast({ title: NOT_TAKEN_BACK });
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +127,7 @@ export function useUndoSent({
|
||||
undoInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[dispatch, invoke, latest, localRef],
|
||||
[dispatch, invoke, latest, localRef, sendSnapshotRef],
|
||||
);
|
||||
|
||||
return undoSent;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { type SendSnapshot, snapshotAtSend } from "./sent-prompt";
|
||||
import { delay, msSinceExposed } from "./timing";
|
||||
import type {
|
||||
ComposerSuggestionsOptions,
|
||||
@@ -33,6 +34,7 @@ export function useSuggestionVerbs({
|
||||
lastSkip,
|
||||
setLastSkip,
|
||||
setSendingItemId,
|
||||
sendSnapshotRef,
|
||||
}: {
|
||||
invoke: SuggestionsInvoke;
|
||||
boundRef: RefObject<string>;
|
||||
@@ -42,6 +44,7 @@ export function useSuggestionVerbs({
|
||||
lastSkip: LastSkip | undefined;
|
||||
setLastSkip: Dispatch<SetStateAction<LastSkip | undefined>>;
|
||||
setSendingItemId: Dispatch<SetStateAction<string | undefined>>;
|
||||
sendSnapshotRef: RefObject<SendSnapshot>;
|
||||
}) {
|
||||
/** A one-key Send is waiting for its verdict; another press then sends nothing. */
|
||||
const sendInFlightRef = useRef(false);
|
||||
@@ -128,6 +131,7 @@ export function useSuggestionVerbs({
|
||||
]);
|
||||
if (verdict?.outcome === "refused") return;
|
||||
if (boundRef.current !== boundSessionId) return;
|
||||
sendSnapshotRef.current = snapshotAtSend(latest.current);
|
||||
latest.current.onSend(ref.prompt);
|
||||
dispatch({ type: "sent", ref });
|
||||
} finally {
|
||||
@@ -141,6 +145,7 @@ export function useSuggestionVerbs({
|
||||
invoke,
|
||||
latest,
|
||||
localRef,
|
||||
sendSnapshotRef,
|
||||
setSendingItemId,
|
||||
signalInert,
|
||||
],
|
||||
@@ -259,9 +264,10 @@ export function useSuggestionVerbs({
|
||||
itemId: used.itemId,
|
||||
input: "key",
|
||||
});
|
||||
sendSnapshotRef.current = snapshotAtSend(latest.current);
|
||||
dispatch({ type: "sent" });
|
||||
},
|
||||
[dispatch, invoke, localRef],
|
||||
[dispatch, invoke, latest, localRef, sendSnapshotRef],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
ToolCallUpdateEvent,
|
||||
} from "@/hooks/chat-session/types";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { withAttachedFilesLabel } from "@/lib/attached-files-label";
|
||||
import {
|
||||
type ChatMessage,
|
||||
ChatMessageImageSchema,
|
||||
@@ -1898,10 +1899,7 @@ export function useChatSession(environmentId: string) {
|
||||
0,
|
||||
attachmentCount - userImages.length,
|
||||
);
|
||||
const userLabel =
|
||||
attachedFileCount > 0
|
||||
? `${prompt}${prompt.length > 0 ? "\n\n" : ""}[attached ${attachedFileCount} file${attachedFileCount === 1 ? "" : "s"}]`
|
||||
: prompt;
|
||||
const userLabel = withAttachedFilesLabel(prompt, attachedFileCount);
|
||||
const promptId = parsed.promptId?.trim();
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
@@ -2837,10 +2835,7 @@ export function useChatSession(environmentId: string) {
|
||||
const attachedFileCount = attachedFiles.filter(
|
||||
(file) => !imageAttachmentMediaType(file),
|
||||
).length;
|
||||
const userLabel =
|
||||
attachedFileCount > 0
|
||||
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFileCount} file${attachedFileCount === 1 ? "" : "s"}]`
|
||||
: trimmed;
|
||||
const userLabel = withAttachedFilesLabel(trimmed, attachedFileCount);
|
||||
const shouldQueue =
|
||||
Boolean(activeSessionId) &&
|
||||
(hasEarlierPromptSubmission ||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { exposedSuggestionMemory } from "@/hooks/composer-suggestions/exposed-memory";
|
||||
import {
|
||||
findSentMessage,
|
||||
type SendSnapshot,
|
||||
samePrompt,
|
||||
} from "@/hooks/composer-suggestions/sent-prompt";
|
||||
import type {
|
||||
@@ -57,6 +58,10 @@ export function useComposerSuggestions(
|
||||
localRef.current = local;
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const sendSnapshotRef = useRef<SendSnapshot>({
|
||||
runCount: 0,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const { invoke, boundRef } = useSlotBinding({
|
||||
enabled,
|
||||
@@ -100,7 +105,8 @@ export function useComposerSuggestions(
|
||||
return () => clearTimeout(timer);
|
||||
}, [local.pendingSince, local.readyAt, local.staleUntil, now]);
|
||||
|
||||
const sentPrompt = local.sent?.prompt;
|
||||
const sent = local.sent;
|
||||
const sentPrompt = sent?.prompt;
|
||||
const queuedSent = useMemo(
|
||||
() =>
|
||||
sentPrompt === undefined
|
||||
@@ -112,10 +118,14 @@ export function useComposerSuggestions(
|
||||
);
|
||||
const sentMessage = useMemo(
|
||||
() =>
|
||||
sentPrompt === undefined
|
||||
sent === undefined
|
||||
? undefined
|
||||
: findSentMessage(options.messages, sentPrompt),
|
||||
[options.messages, sentPrompt],
|
||||
: findSentMessage(
|
||||
options.messages,
|
||||
sent.prompt,
|
||||
sendSnapshotRef.current.runCount,
|
||||
),
|
||||
[options.messages, sent],
|
||||
);
|
||||
const canUndo = Boolean(queuedSent || sentMessage);
|
||||
const queued = options.promptsInQueue.length;
|
||||
@@ -179,6 +189,7 @@ export function useComposerSuggestions(
|
||||
lastSkip,
|
||||
setLastSkip,
|
||||
setSendingItemId,
|
||||
sendSnapshotRef,
|
||||
});
|
||||
const undoSent = useUndoSent({
|
||||
status,
|
||||
@@ -187,6 +198,7 @@ export function useComposerSuggestions(
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
sendSnapshotRef,
|
||||
});
|
||||
|
||||
return useMemo<ComposerSuggestionsController>(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const ATTACHED_FILES_LABEL = /(?:^|\n\n)\[attached \d+ files?\]$/;
|
||||
|
||||
/** How a sent prompt shows the non-image files that went with it. */
|
||||
export function withAttachedFilesLabel(
|
||||
prompt: string,
|
||||
fileCount: number,
|
||||
): string {
|
||||
if (fileCount <= 0) return prompt;
|
||||
const label = `[attached ${fileCount} file${fileCount === 1 ? "" : "s"}]`;
|
||||
return prompt ? `${prompt}\n\n${label}` : label;
|
||||
}
|
||||
|
||||
export function withoutAttachedFilesLabel(content: string): string {
|
||||
return content.replace(ATTACHED_FILES_LABEL, "");
|
||||
}
|
||||
Reference in New Issue
Block a user