From 7cbaff4b22783cd0fb5e5ce846380e63236e0f34 Mon Sep 17 00:00:00 2001 From: Dominic Vonk Date: Tue, 15 Sep 2026 20:53:08 +0000 Subject: [PATCH 1/2] feat(web): edit and remove queued messages --- apps/web/src/components/ChatView.tsx | 17 ++++++++- .../src/components/chat/MessagesTimeline.tsx | 29 +++++++++++++-- apps/web/src/queuedMessageStore.test.ts | 37 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d0d05146c489..c9d54e424b3e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8205,6 +8205,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 = { @@ -8216,7 +8217,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) => { @@ -8225,6 +8236,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; @@ -9501,6 +9515,7 @@ export default function ChatView(props: ChatViewProps) { loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} onSteerQueuedMessage={onSteerQueuedMessage} + onEditQueuedMessage={onEditQueuedMessage} onRemoveQueuedMessage={onRemoveQueuedMessage} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 61381ddb71f7..2a59ab89023f 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 { @@ -285,6 +285,7 @@ interface TimelineRowSharedState { onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; onSteerQueuedMessage: (id: string) => void; + onEditQueuedMessage: (id: string) => void; onRemoveQueuedMessage: (id: string) => void; } @@ -441,6 +442,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; onRemoveQueuedMessage?: (id: string) => void; } @@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, queuedMessages = EMPTY_QUEUED_MESSAGES, onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + onEditQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, onSteerQueuedMessage, + onEditQueuedMessage, onRemoveQueuedMessage, }), [ @@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, onSteerQueuedMessage, + onEditQueuedMessage, onRemoveQueuedMessage, ], ); @@ -1566,6 +1571,24 @@ function QueuedMessageTimelineRow({ Send now + + 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")); From 262144086f460a7df33c0836ae8b5df13c51ac47 Mon Sep 17 00:00:00 2001 From: Dominic Vonk Date: Tue, 15 Sep 2026 21:24:58 +0000 Subject: [PATCH 2/2] fix(web): sync restored queue attachments from the draft --- apps/web/src/components/ChatView.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9d54e424b3e..2610c5281934 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7115,12 +7115,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: "",