feat(web): edit a queued message from the composer strip (#2019)

* feat(web): edit a queued message from the composer strip

Each queued row gets a pencil button that pulls the message back into the
composer for editing: its text and attachments load into the composer, the
entry is removed from the queue, and the textarea is focused. Any
in-progress draft is preserved (prepended). Re-sending re-queues it (busy)
or sends it (idle).

Stacked on the delete PR.

Co-authored-by: Isaac

* fix(web): edit replaces composer content instead of prepending

Editing a queued message now replaces the composer's text and attachments
with the queued message's, rather than prepending to an in-progress draft
— prepending was surprising when the composer already held content.

Co-authored-by: Isaac
This commit is contained in:
Serena Ruan
2026-07-06 17:16:23 +08:00
committed by GitHub
parent 8552d68c7e
commit 31db1dcafe
3 changed files with 49 additions and 5 deletions
+12
View File
@@ -4367,6 +4367,18 @@ export function Composer({
<QueuedMessagesStrip
messages={queuedMessages.filter((m) => m.conversationId === conversationId)}
onDelete={dequeueMessage}
onEdit={(queueId) => {
// Pull the queued message back into the composer for editing:
// replace the composer's text + attachments with the queued
// message's, remove it from the queue, and focus the textarea.
// Re-sending re-queues it (busy) or sends it (idle).
const target = queuedMessages.find((m) => m.queueId === queueId);
if (!target) return;
setValue(target.text);
setFiles(target.files ?? []);
dequeueMessage(queueId);
textareaRef.current?.focus();
}}
widthClassName={CHAT_COLUMN_WIDTH}
/>
{/* Sub-agent context tray — peeks above the card; reserves its own
+21 -1
View File
@@ -18,7 +18,9 @@ afterEach(cleanup);
describe("QueuedMessagesStrip", () => {
it("renders nothing when the queue is empty", () => {
const { container } = render(<QueuedMessagesStrip messages={[]} onDelete={vi.fn()} />);
const { container } = render(
<QueuedMessagesStrip messages={[]} onDelete={vi.fn()} onEdit={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
@@ -27,6 +29,7 @@ describe("QueuedMessagesStrip", () => {
<QueuedMessagesStrip
messages={[msg("q_1", "first"), msg("q_2", "second")]}
onDelete={vi.fn()}
onEdit={vi.fn()}
/>,
);
expect(screen.getByText("first")).toBeInTheDocument();
@@ -41,6 +44,7 @@ describe("QueuedMessagesStrip", () => {
<QueuedMessagesStrip
messages={[msg("q_1", "first"), msg("q_2", "second")]}
onDelete={onDelete}
onEdit={vi.fn()}
/>,
);
const buttons = screen.getAllByRole("button", { name: "Remove queued message" });
@@ -49,4 +53,20 @@ describe("QueuedMessagesStrip", () => {
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith("q_2");
});
it("calls onEdit with the row's queueId when its edit button is clicked", () => {
const onEdit = vi.fn();
render(
<QueuedMessagesStrip
messages={[msg("q_1", "first"), msg("q_2", "second")]}
onDelete={vi.fn()}
onEdit={onEdit}
/>,
);
const buttons = screen.getAllByRole("button", { name: "Edit queued message" });
expect(buttons).toHaveLength(2);
fireEvent.click(buttons[0]!);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith("q_1");
});
});
+16 -4
View File
@@ -1,4 +1,4 @@
import { ClockIcon, Trash2Icon } from "lucide-react";
import { ClockIcon, PencilIcon, Trash2Icon } from "lucide-react";
import type { QueuedMessage } from "@/store/chatStore";
import { cn } from "@/lib/utils";
@@ -8,6 +8,8 @@ interface QueuedMessagesStripProps {
messages: QueuedMessage[];
/** Remove a queued message by id (per-row delete). */
onDelete: (queueId: string) => void;
/** Pull a queued message back into the composer for editing. */
onEdit: (queueId: string) => void;
/** Column-width class so the strip lines up with the composer card. */
widthClassName?: string;
}
@@ -17,11 +19,13 @@ interface QueuedMessagesStripProps {
* busy. Peeks above the composer card (`-mb-4` + bottom padding), mirroring
* `SubagentComposerTray`. Renders nothing when the queue is empty.
*
* Each row can be deleted; edit / steer / reorder land in later changes.
* Each row can be edited (pulled back into the composer) or deleted; steer /
* reorder land in later changes.
*/
export function QueuedMessagesStrip({
messages,
onDelete,
onEdit,
widthClassName,
}: QueuedMessagesStripProps) {
if (messages.length === 0) return null;
@@ -44,8 +48,16 @@ export function QueuedMessagesStrip({
<ClockIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{message.text}</span>
<span className="shrink-0 text-muted-foreground/70">Queued</span>
{/* Always visible (not hover-gated) so delete is discoverable; it
brightens on hover/focus. */}
{/* Always visible (not hover-gated) so the actions are
discoverable; they brighten on hover/focus. */}
<button
type="button"
aria-label="Edit queued message"
className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition hover:text-foreground focus-visible:text-foreground"
onClick={() => onEdit(message.queueId)}
>
<PencilIcon className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Remove queued message"