diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index e553602ec6..23e16f2cad 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -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, diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx index 33a5055523..427a536334 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx @@ -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[0]["attachments"]; + model?: string; }) { await act(async () => { root.render( { ); }); + 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( + '[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( + '[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( + '[data-testid="suggestion-legend"] button[data-verb="send"]', + ) + ?.click(); + }); + + expect(suggestions.send).not.toHaveBeenCalled(); + }); + describe("one-key Send", () => { let drafts: Map; beforeEach(() => { diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index d738298eef..1f92eb3c18 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -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(null); const [transcriptionTarget, setTranscriptionTarget] = useState(null); @@ -1708,6 +1707,7 @@ function ChatInputBarImpl({ composerInputRef={promptInputRef} composerMenuOpen={slashOpen || mentionOpen} draft={promptInput} + refusesSend={composerRefusesSend} setDraft={setPromptInput} suggestions={suggestions} /> diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx index 121cf3715b..c3cea61b3a 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx @@ -43,6 +43,7 @@ export type SuggestionTrayProps = { suggestions: ComposerSuggestionsController; draft: string; setDraft: (value: string) => void; + refusesSend: () => boolean; composerInputRef: RefObject; /** 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], ); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.test.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.test.ts new file mode 100644 index 0000000000..4879b9d758 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.test.ts @@ -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]"); + }); +}); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts index 3501bd6822..8020f3f557 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts @@ -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; } diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx index 29ed82486a..229cf844e8 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx @@ -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} /> diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts index dfaa054b36..ed7009cc18 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts @@ -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. */ diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts index aa65a06db8..eb7f73094d 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts @@ -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(); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts index 14e555b4e1..b3a1c5dcb3 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts @@ -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": diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts index d6997b47a7..d9f7251bba 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts @@ -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; latest: RefObject; dispatch: SuggestionsDispatch; + sendSnapshotRef: RefObject; }) { 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; diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts index 57f9f0ca26..b1a2fe4d71 100644 --- a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts @@ -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; @@ -42,6 +44,7 @@ export function useSuggestionVerbs({ lastSkip: LastSkip | undefined; setLastSkip: Dispatch>; setSendingItemId: Dispatch>; + sendSnapshotRef: RefObject; }) { /** 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( diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts index 3222ca0ef4..444bf6d142 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts @@ -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 || diff --git a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts index 64b3462a71..d639a3178c 100644 --- a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts +++ b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts @@ -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({ + 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( diff --git a/apps/examples/desktop-app/webview/lib/attached-files-label.ts b/apps/examples/desktop-app/webview/lib/attached-files-label.ts new file mode 100644 index 0000000000..757f4fa8f1 --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/attached-files-label.ts @@ -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, ""); +}