diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708809712917..d4006f85654e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7129,12 +7129,13 @@ export default function ChatView(props: ChatViewProps) { const overflow = attachments.slice(attachmentRoom); const restoredImages = restored.filter((attachment) => attachment.type === "image"); const restoredFiles = restored.filter((attachment) => attachment.type === "file"); - // The composer syncs these refs from the draft in an effect; a send before - // that effect runs must already see the restored content. - composerImagesRef.current = [...composerImagesRef.current, ...restoredImages]; - composerFilesRef.current = [...composerFilesRef.current, ...restoredFiles]; if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages); if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles); + // The store can reject duplicates or replace file reattachment markers. + // An immediate send must use the accepted draft, before the ref-sync effects run. + const restoredDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + composerImagesRef.current = restoredDraft?.images ?? []; + composerFilesRef.current = restoredDraft?.files ?? []; if (overflow.length > 0 && activeThreadKey) { useQueuedMessageStore.getState().enqueue(activeThreadKey, { prompt: "", @@ -8221,6 +8222,7 @@ export default function ChatView(props: ChatViewProps) { // stable and does not bust TimelineRowCtx on every ChatView render. const queuedMessageActionsRef = useRef({ steer: (_id: string) => {}, + edit: (_id: string) => {}, remove: (_id: string) => {}, }); queuedMessageActionsRef.current = { @@ -8232,7 +8234,17 @@ export default function ChatView(props: ChatViewProps) { remove: (id) => { if (!activeThreadKey) return; const message = useQueuedMessageStore.getState().remove(activeThreadKey, id); - if (message) restoreQueuedMessagesToComposer([message]); + for (const image of message?.images ?? []) { + revokeBlobPreviewUrl(image.previewUrl); + } + }, + edit: (id) => { + if (!activeThreadKey) return; + const message = useQueuedMessageStore.getState().remove(activeThreadKey, id); + if (message) { + restoreQueuedMessagesToComposer([message]); + focusComposer(); + } }, }; const onSteerQueuedMessage = useCallback((id: string) => { @@ -8241,6 +8253,9 @@ export default function ChatView(props: ChatViewProps) { const onRemoveQueuedMessage = useCallback((id: string) => { queuedMessageActionsRef.current.remove(id); }, []); + const onEditQueuedMessage = useCallback((id: string) => { + queuedMessageActionsRef.current.edit(id); + }, []); // Stop also cancels the queue: the messages return to the composer instead // of starting a new turn the moment the interrupted one settles. restoreQueuedMessagesRef.current = restoreQueuedMessagesToComposer; @@ -9517,6 +9532,7 @@ export default function ChatView(props: ChatViewProps) { loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} onSteerQueuedMessage={onSteerQueuedMessage} + onEditQueuedMessage={onEditQueuedMessage} steerQueuedMessageShortcutLabel={shortcutLabelForCommand( keybindings, "thread.steerQueuedMessage", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 976f261981e3..64862ea01430 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,4 @@ -import { ArrowUpIcon, ClockIcon } from "lucide-react"; +import { ArrowUpIcon, ClockIcon, PencilIcon } from "lucide-react"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { @@ -288,6 +288,7 @@ interface TimelineRowSharedState { onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; onSteerQueuedMessage: (id: string) => void; + onEditQueuedMessage: (id: string) => void; steerQueuedMessageShortcutLabel: string | null; onRemoveQueuedMessage: (id: string) => void; } @@ -451,6 +452,7 @@ interface MessagesTimelineProps { /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ queuedMessages?: ReadonlyArray; onSteerQueuedMessage?: (id: string) => void; + onEditQueuedMessage?: (id: string) => void; steerQueuedMessageShortcutLabel?: string | null; onRemoveQueuedMessage?: (id: string) => void; } @@ -507,6 +509,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, queuedMessages = EMPTY_QUEUED_MESSAGES, onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + onEditQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { @@ -952,6 +955,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, onSteerQueuedMessage, + onEditQueuedMessage, steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, }), @@ -985,6 +989,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, onSteerQueuedMessage, + onEditQueuedMessage, steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, ], @@ -1600,6 +1605,24 @@ function QueuedMessageTimelineRow({ : null} + + event.preventDefault()} + onClick={() => ctx.onEditQueuedMessage(queuedMessage.id)} + aria-label="Edit queued message" + /> + } + > + + + Edit in composer + event.preventDefault()} onClick={() => ctx.onRemoveQueuedMessage(queuedMessage.id)} - aria-label="Cancel and return to the composer" + aria-label="Remove queued message" /> } > - Cancel and return to the composer + Remove queued message diff --git a/apps/web/src/queuedMessageStore.test.ts b/apps/web/src/queuedMessageStore.test.ts index 33869d89da62..f43f6f1d5fda 100644 --- a/apps/web/src/queuedMessageStore.test.ts +++ b/apps/web/src/queuedMessageStore.test.ts @@ -70,6 +70,43 @@ describe("queuedMessageStore", () => { expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([first]); }); + it("a removed message cannot be dispatched from a stale queue snapshot", () => { + const { enqueue, remove, take } = useQueuedMessageStore.getState(); + const message = enqueue("thread-a", makeMessage("remove me")); + const other = enqueue("thread-b", makeMessage("keep me")); + + remove("thread-a", message.id); + + expect(take("thread-a", message.id, "next-tool")).toBeNull(); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined(); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-b"]).toEqual([other]); + }); + + it("returns the full message for editing while leaving the next message queued", () => { + const { enqueue, remove } = useQueuedMessageStore.getState(); + const image = { + type: "image" as const, + id: "image-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 5, + previewUrl: "blob:queued-image", + file: new File(["image"], "screenshot.png", { type: "image/png" }), + }; + const message = enqueue("thread-a", { + ...makeMessage("edit with attachment"), + images: [image], + }); + const next = enqueue("thread-a", makeMessage("send later")); + + const editable = remove("thread-a", message.id); + + expect(editable).toEqual(message); + expect(editable?.images[0]?.file).toBe(image.file); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([next]); + expect(remove("thread-a", message.id)).toBeNull(); + }); + it("holdAtFront returns a failed message to the head, held", () => { const { enqueue, take, holdAtFront } = useQueuedMessageStore.getState(); const first = enqueue("thread-a", makeMessage("first"));