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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user