Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -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 = {
Expand All @@ -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]);
Comment thread
DominicVonk marked this conversation as resolved.
focusComposer();
}
},
};
const onSteerQueuedMessage = useCallback((id: string) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -451,6 +452,7 @@ interface MessagesTimelineProps {
/** Messages sent during the running turn. They render as ghost bubbles after the live rows. */
queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
onSteerQueuedMessage?: (id: string) => void;
onEditQueuedMessage?: (id: string) => void;
steerQueuedMessageShortcutLabel?: string | null;
onRemoveQueuedMessage?: (id: string) => void;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -952,6 +955,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
onSteerQueuedMessage,
onEditQueuedMessage,
steerQueuedMessageShortcutLabel,
onRemoveQueuedMessage,
}),
Expand Down Expand Up @@ -985,6 +989,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onWorktreeSetupWorkLocally,
onOpenWorktreeSetupTerminal,
onSteerQueuedMessage,
onEditQueuedMessage,
steerQueuedMessageShortcutLabel,
onRemoveQueuedMessage,
],
Expand Down Expand Up @@ -1600,6 +1605,24 @@ function QueuedMessageTimelineRow({
: null}
</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
className="size-6"
onPointerDown={(event) => event.preventDefault()}
onClick={() => ctx.onEditQueuedMessage(queuedMessage.id)}
aria-label="Edit queued message"
/>
}
>
<PencilIcon className="size-3.5" aria-hidden />
</TooltipTrigger>
<TooltipPopup side="bottom">Edit in composer</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
Expand All @@ -1610,13 +1633,13 @@ function QueuedMessageTimelineRow({
className="size-6"
onPointerDown={(event) => event.preventDefault()}
onClick={() => ctx.onRemoveQueuedMessage(queuedMessage.id)}
aria-label="Cancel and return to the composer"
aria-label="Remove queued message"
/>
}
>
<XIcon className="size-3.5" aria-hidden />
</TooltipTrigger>
<TooltipPopup side="bottom">Cancel and return to the composer</TooltipPopup>
<TooltipPopup side="bottom">Remove queued message</TooltipPopup>
</Tooltip>
</div>
</div>
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/queuedMessageStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading