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:
Harrison
2026-10-22 15:31:54 -07:00
parent 325b8c4512
commit 205eeaa55b
15 changed files with 381 additions and 57 deletions
@@ -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}
/>
@@ -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, "");
}