From 573f2a09015adab3c468b30205998819ce4c1d88 Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 14:42:33 -0700
Subject: [PATCH 1/7] feat(desktop): delete a message by clearing its edit to
 empty
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Editing a message down to an empty body and submitting used to be a
deliberate no-op ("don't let edit become an effective deletion"). But a
common workflow is to delete a message by opening its edit, clearing the
text, and hitting accept — which just hung.

Now an empty edit submit (no text, no attachments) is the keyboard
shorthand for the "Delete message" action: it surfaces the same
confirmation dialog and, on confirm, deletes the message and exits edit
mode. A confirmation — rather than an instant delete — means an
accidental clear-and-Enter can't destroy a message with no undo. When no
delete handler is wired (e.g. archived channels) it stays an inert
no-op, so an empty edit can never publish an empty body.

Wired through both the main timeline and thread composers. The decision
logic lives in a pure, unit-tested helper (emptyEditDelete.ts).

The MessageComposer file-size ratchet forbids growing that already-large
file, so the feature lives in a dedicated hook (useEmptyEditDelete) and
the self-contained insertEmoji callback was lifted into its own hook
(useComposerInsertEmoji) to make room — no behavior change to emoji
insertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 .../src/features/channels/ui/ChannelPane.tsx  |   3 +
 .../features/channels/ui/ChannelPane.types.ts |   1 +
 .../features/channels/ui/ChannelScreen.tsx    |   6 +
 .../channels/useChannelPaneHandlers.ts        |  13 ++
 .../features/messages/ui/MessageComposer.tsx  |  67 +++++-----
 .../messages/ui/MessageComposer.types.ts      |   9 ++
 .../messages/ui/MessageThreadPanel.tsx        |   3 +
 .../messages/ui/emptyEditDelete.test.mjs      |  26 ++++
 .../features/messages/ui/emptyEditDelete.ts   |  26 ++++
 .../messages/ui/useComposerInsertEmoji.ts     |  62 +++++++++
 .../messages/ui/useEmptyEditDelete.tsx        | 119 ++++++++++++++++++
 11 files changed, 299 insertions(+), 36 deletions(-)
 create mode 100644 desktop/src/features/messages/ui/emptyEditDelete.test.mjs
 create mode 100644 desktop/src/features/messages/ui/emptyEditDelete.ts
 create mode 100644 desktop/src/features/messages/ui/useComposerInsertEmoji.ts
 create mode 100644 desktop/src/features/messages/ui/useEmptyEditDelete.tsx

diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 70877875f7..55f5cea47e 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -113,6 +113,7 @@ export const ChannelPane = React.memo(function ChannelPane({
   onDelete,
   onEdit,
   onEditSave,
+  onDeleteEditTarget,
   onFollowThread,
   onMarkUnread,
   onMarkRead,
@@ -762,6 +763,7 @@ export const ChannelPane = React.memo(function ChannelPane({
                   onCancelEdit={onCancelEdit}
                   onEditLastOwnMessage={handleEditLastOwnMainMessage}
                   onEditSave={onEditSave}
+                  onDeleteEditTarget={onDeleteEditTarget}
                   onPrepareSendChannel={
                     activeChannel?.channelType === "dm"
                       ? prepareDmSendChannel
@@ -861,6 +863,7 @@ export const ChannelPane = React.memo(function ChannelPane({
                 onEdit={onEdit}
                 onEditLastOwnMessage={handleEditLastOwnThreadMessage}
                 onEditSave={onEditSave}
+                onDeleteEditTarget={onDeleteEditTarget}
                 onFollowThread={onFollowThread}
                 onMarkUnread={onMarkUnread}
                 onMarkRead={onMarkRead}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 7257d8cd55..2e59dcd5bc 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -88,6 +88,7 @@ export type ChannelPaneProps = {
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
+  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   onMarkUnread?: (message: TimelineMessage) => void;
   onMarkRead?: (message: TimelineMessage) => void;
   onExpandThreadReplies: (message: TimelineMessage) => void;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 7b750daa4f..441f162565 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -488,6 +488,7 @@ export function ChannelScreen({
     handleCancelThreadReply,
     handleCloseThread,
     handleDelete,
+    handleDeleteEditTarget,
     handleEdit,
     handleEditSave,
     handleExpandThreadReplies,
@@ -911,6 +912,11 @@ export function ChannelScreen({
                   onEditSave={
                     activeChannel?.archivedAt ? undefined : handleEditSave
                   }
+                  onDeleteEditTarget={
+                    activeChannel?.archivedAt
+                      ? undefined
+                      : handleDeleteEditTarget
+                  }
                   onMarkUnread={handleMessageMarkUnread}
                   onMarkRead={handleMessageMarkRead}
                   onExpandThreadReplies={handleExpandThreadReplies}
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index 8c5f54fffc..a1e2cd2a12 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -133,6 +133,18 @@ export function useChannelPaneHandlers({
     await deleteMutateRef.current({ eventId: message.id }).catch(() => {});
   }, []);
 
+  // Delete the message currently being edited and leave edit mode. Fired when
+  // the user clears an edit to empty and confirms — the keyboard equivalent of
+  // the "Delete message" action. Exit edit mode first so the composer collapses
+  // immediately; the delete follows (its own onError toast surfaces failures).
+  const handleDeleteEditTarget = React.useCallback(
+    async (eventId: string) => {
+      setEditTargetId(null);
+      await deleteMutateRef.current({ eventId }).catch(() => {});
+    },
+    [setEditTargetId],
+  );
+
   const handleEdit = React.useCallback(
     (message: { id: string }) => {
       setEditTargetId((current) =>
@@ -341,6 +353,7 @@ export function useChannelPaneHandlers({
     handleCancelThreadReply,
     handleCloseThread,
     handleDelete,
+    handleDeleteEditTarget,
     handleEdit,
     handleEditSave,
     handleExpandThreadReplies,
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index df3a734cee..c451934c6b 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -30,7 +30,6 @@ import {
   hasMentionClipboardHtml,
   normalizeMentionClipboardHtml,
 } from "@/features/messages/lib/normalizeMentionClipboard";
-import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
 import {
   type AutocompleteEdit,
   type LinkSelectionInfo,
@@ -54,6 +53,8 @@ import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
 import { useMentionSendFlow } from "./useMentionSendFlow";
 import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
 import { useComposerContentState } from "./useComposerContentState";
+import { useComposerInsertEmoji } from "./useComposerInsertEmoji";
+import { useEmptyEditDelete } from "./useEmptyEditDelete";
 import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
 
 import type { MessageComposerProps } from "./MessageComposer.types";
@@ -76,6 +77,7 @@ function MessageComposerImpl({
   onCaptureSendContext,
   onEditLastOwnMessage,
   onEditSave,
+  onDeleteEditTarget,
   onPrepareSendChannel,
   onPreparingMentionSendChange,
   onSend,
@@ -438,41 +440,26 @@ function MessageComposerImpl({
   );
 
   // ── Emoji insertion ─────────────────────────────────────────────────
-  const insertEmoji = React.useCallback(
-    (emoji: string) => {
-      if (!richText.editor) return;
-      // A `:shortcode:` for a known custom emoji becomes a selectable atom
-      // node (same as the input rule / autocomplete), so it can be selected,
-      // copied, and deleted as one unit. Everything else (native unicode)
-      // inserts as plain content.
-      const match = /^:([^:\s]+):$/.exec(emoji);
-      const shortcode = match?.[1]?.toLowerCase();
-      const known =
-        shortcode &&
-        customEmoji.some((e) => e.shortcode.toLowerCase() === shortcode);
-      if (known && shortcode) {
-        richText.editor
-          .chain()
-          .focus()
-          .insertContent({
-            type: CUSTOM_EMOJI_NODE_NAME,
-            attrs: {
-              shortcode,
-              src:
-                customEmoji.find((e) => e.shortcode.toLowerCase() === shortcode)
-                  ?.url ?? "",
-            },
-          })
-          .insertContent(" ")
-          .run();
-      } else {
-        richText.editor.chain().focus().insertContent(emoji).run();
-      }
+  const insertEmoji = useComposerInsertEmoji({
+    editor: richText.editor,
+    customEmoji,
+    onAfterInsert: () => {
       setIsEmojiPickerOpen(false);
       mentions.clearMentions();
     },
-    [richText.editor, mentions.clearMentions, customEmoji],
-  );
+  });
+
+  // ── Empty-edit → delete ─────────────────────────────────────────────
+  // Clearing an edit to empty and submitting is the keyboard shorthand for
+  // "Delete message"; the hook owns the confirmation dialog + delete handoff.
+  const { requestEmptyEditDelete, emptyEditDeleteDialog } = useEmptyEditDelete({
+    editTargetRef,
+    onDeleteEditTarget,
+    clearComposerBody: () => {
+      setComposerContent("");
+      richText.clearContent();
+    },
+  });
 
   // ── @ mention picker (toolbar button) ───────────────────────────────
   const openMentionPicker = React.useCallback(() => {
@@ -514,9 +501,14 @@ function MessageComposerImpl({
       if (isSendingRef.current || isUploadingRef.current) return;
       const currentPendingImeta = media.pendingImetaRef.current;
       const hasMedia = currentPendingImeta.length > 0;
-      // Empty text + zero attachments is a no-op (don't let edit become an
-      // effective deletion).
-      if (!trimmed && !hasMedia) return;
+      // Empty text + zero attachments: clearing an edit to nothing is the
+      // keyboard shorthand for deleting the message (confirm-then-delete via
+      // the hook). No-ops when no delete handler is wired, so an empty edit
+      // never silently publishes an empty body.
+      if (!trimmed && !hasMedia) {
+        requestEmptyEditDelete();
+        return;
+      }
 
       // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []`
       // because edit semantics use `[]` as the explicit "wipe all
@@ -628,6 +620,7 @@ function MessageComposerImpl({
     mentionSendFlow.isPreparingMentionSend,
     mentionSendFlow.sendMessageWithMentionFlow,
     mentions.clearMentions,
+    requestEmptyEditDelete,
     richText.clearContent,
     richText.setContent,
     setComposerContent,
@@ -1008,6 +1001,8 @@ function MessageComposerImpl({
         </div>
       </footer>
 
+      {emptyEditDeleteDialog}
+
       <NonMemberMentionDialog
         error={mentionSendFlow.nonMemberPromptError}
         isInvitePending={mentionSendFlow.isInvitePending}
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts
index 800474c3dd..3039be369a 100644
--- a/desktop/src/features/messages/ui/MessageComposer.types.ts
+++ b/desktop/src/features/messages/ui/MessageComposer.types.ts
@@ -67,6 +67,15 @@ export type MessageComposerProps = {
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
+  /**
+   * Invoked when the user submits an edit whose body has been cleared to empty
+   * (no text and no attachments). Clearing an edit to nothing is the keyboard
+   * shorthand for deleting the message: the composer surfaces the same
+   * confirmation the "Delete message" action uses and only calls this after
+   * the user confirms. The owner should delete the message identified by
+   * `eventId` and exit edit mode. When omitted, an empty edit stays a no-op.
+   */
+  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   /** Captures send context synchronously before awaits can change navigation. */
   onCaptureSendContext?: () => {
     parentEventId: string | null;
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 6234af22d1..ee5076eb2a 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -77,6 +77,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
+  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   onMarkUnread?: (message: TimelineMessage) => void;
   onMarkRead?: (message: TimelineMessage) => void;
   onExpandReplies: (message: TimelineMessage) => void;
@@ -206,6 +207,7 @@ export function MessageThreadPanel({
   onEdit,
   onEditLastOwnMessage,
   onEditSave,
+  onDeleteEditTarget,
   onFollowThread,
   onMarkUnread,
   onMarkRead,
@@ -877,6 +879,7 @@ export function MessageThreadPanel({
               onCaptureSendContext={onCaptureSendContext}
               onEditLastOwnMessage={onEditLastOwnMessage}
               onEditSave={onEditSave}
+              onDeleteEditTarget={onDeleteEditTarget}
               onSend={onSend}
               placeholder={`Reply in thread to ${threadHead.author}`}
               profiles={profiles}
diff --git a/desktop/src/features/messages/ui/emptyEditDelete.test.mjs b/desktop/src/features/messages/ui/emptyEditDelete.test.mjs
new file mode 100644
index 0000000000..12093ee2de
--- /dev/null
+++ b/desktop/src/features/messages/ui/emptyEditDelete.test.mjs
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { resolveEmptyEditDelete } from "./emptyEditDelete.ts";
+
+test("deletes the edited message when a handler is wired", () => {
+  assert.equal(resolveEmptyEditDelete("evt-123", true), "evt-123");
+});
+
+test("no-op when no delete handler is wired (never destroys silently)", () => {
+  assert.equal(resolveEmptyEditDelete("evt-123", false), null);
+});
+
+test("no-op when there is no message loaded for editing", () => {
+  assert.equal(resolveEmptyEditDelete(null, true), null);
+  assert.equal(resolveEmptyEditDelete(undefined, true), null);
+});
+
+test("no-op on a blank target id", () => {
+  assert.equal(resolveEmptyEditDelete("", true), null);
+});
+
+test("requires both a target and a handler", () => {
+  assert.equal(resolveEmptyEditDelete(null, false), null);
+  assert.equal(resolveEmptyEditDelete("", false), null);
+});
diff --git a/desktop/src/features/messages/ui/emptyEditDelete.ts b/desktop/src/features/messages/ui/emptyEditDelete.ts
new file mode 100644
index 0000000000..3058276bc3
--- /dev/null
+++ b/desktop/src/features/messages/ui/emptyEditDelete.ts
@@ -0,0 +1,26 @@
+/**
+ * Pure decision logic for the "clear an edit to empty = delete the message"
+ * composer shortcut. Kept free of React so the rule can be unit-tested
+ * directly.
+ */
+
+/**
+ * Resolve which message a submitted empty edit should delete.
+ *
+ * Returns the event id to delete, or `null` when the deletion must not
+ * proceed — either no message is loaded for editing (blank/absent id) or no
+ * delete handler is wired. In the no-handler case an empty edit stays a no-op
+ * rather than destroying anything, preserving the historical guard.
+ *
+ * Used at two moments with the same rule: deciding whether to surface the
+ * confirmation on submit, and resolving the target when the user confirms.
+ */
+export function resolveEmptyEditDelete(
+  editTargetId: string | null | undefined,
+  hasDeleteHandler: boolean,
+): string | null {
+  if (!hasDeleteHandler || !editTargetId) {
+    return null;
+  }
+  return editTargetId;
+}
diff --git a/desktop/src/features/messages/ui/useComposerInsertEmoji.ts b/desktop/src/features/messages/ui/useComposerInsertEmoji.ts
new file mode 100644
index 0000000000..e99ce3fa17
--- /dev/null
+++ b/desktop/src/features/messages/ui/useComposerInsertEmoji.ts
@@ -0,0 +1,62 @@
+import * as React from "react";
+
+import type { Editor } from "@tiptap/react";
+
+import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
+import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
+
+type InsertEmojiParams = {
+  editor: Editor | null;
+  customEmoji: CustomEmoji[];
+  /** Side effects to run after an insert (close the picker, clear mentions). */
+  onAfterInsert: () => void;
+};
+
+/**
+ * Insert an emoji into the composer from the toolbar emoji picker.
+ *
+ * A `:shortcode:` for a known custom emoji becomes a selectable atom node (same
+ * as the input rule / autocomplete) so it can be selected, copied, and deleted
+ * as one unit; everything else (native unicode) inserts as plain content.
+ */
+export function useComposerInsertEmoji({
+  editor,
+  customEmoji,
+  onAfterInsert,
+}: InsertEmojiParams) {
+  // Keep the post-insert side effects in a ref so the returned callback's
+  // identity tracks only editor/customEmoji, exactly as it did inline.
+  const onAfterInsertRef = React.useRef(onAfterInsert);
+  onAfterInsertRef.current = onAfterInsert;
+
+  return React.useCallback(
+    (emoji: string) => {
+      if (!editor) return;
+      const match = /^:([^:\s]+):$/.exec(emoji);
+      const shortcode = match?.[1]?.toLowerCase();
+      const known =
+        shortcode &&
+        customEmoji.some((e) => e.shortcode.toLowerCase() === shortcode);
+      if (known && shortcode) {
+        editor
+          .chain()
+          .focus()
+          .insertContent({
+            type: CUSTOM_EMOJI_NODE_NAME,
+            attrs: {
+              shortcode,
+              src:
+                customEmoji.find((e) => e.shortcode.toLowerCase() === shortcode)
+                  ?.url ?? "",
+            },
+          })
+          .insertContent(" ")
+          .run();
+      } else {
+        editor.chain().focus().insertContent(emoji).run();
+      }
+      onAfterInsertRef.current();
+    },
+    [editor, customEmoji],
+  );
+}
diff --git a/desktop/src/features/messages/ui/useEmptyEditDelete.tsx b/desktop/src/features/messages/ui/useEmptyEditDelete.tsx
new file mode 100644
index 0000000000..5e8c897e54
--- /dev/null
+++ b/desktop/src/features/messages/ui/useEmptyEditDelete.tsx
@@ -0,0 +1,119 @@
+import * as React from "react";
+
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import { Button } from "@/shared/ui/button";
+
+import { resolveEmptyEditDelete } from "./emptyEditDelete";
+
+type EmptyEditDeleteParams = {
+  /** Live ref to the message currently loaded into the composer for editing. */
+  editTargetRef: React.RefObject<{ id: string } | null | undefined>;
+  /**
+   * Owner handler that deletes the edited message and exits edit mode. When
+   * undefined, clearing an edit to empty stays an inert no-op.
+   */
+  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
+  /** Empties the composer body so it doesn't linger after the delete. */
+  clearComposerBody: () => void;
+};
+
+/**
+ * Turns "clear an edit to empty and submit" into the delete-message action.
+ *
+ * Deleting a message by right-click → "Delete message" pops a confirmation
+ * before anything is destroyed; this mirrors that exactly. `requestDelete`
+ * (called from the composer's submit guard when the edited body is empty and
+ * has no attachments) opens the same confirmation, and only on confirm does it
+ * clear the composer and hand the target id to the owner's delete handler. A
+ * confirmation — rather than an instant delete — means an accidental
+ * clear-and-Enter can't destroy a message with no undo.
+ *
+ * Returns the trigger plus the dialog element to render inside the composer.
+ * No-ops (and never opens the dialog) when no delete handler is wired.
+ */
+export function useEmptyEditDelete({
+  editTargetRef,
+  onDeleteEditTarget,
+  clearComposerBody,
+}: EmptyEditDeleteParams) {
+  const [isOpen, setIsOpen] = React.useState(false);
+
+  // Stash the owner handler + clear callback in refs so the returned callbacks
+  // stay reference-stable across renders (the composer feeds them into large
+  // dependency arrays and memoized children).
+  const onDeleteEditTargetRef = React.useRef(onDeleteEditTarget);
+  onDeleteEditTargetRef.current = onDeleteEditTarget;
+  const clearComposerBodyRef = React.useRef(clearComposerBody);
+  clearComposerBodyRef.current = clearComposerBody;
+
+  const requestDelete = React.useCallback(() => {
+    // No target / no delete handler → keep the historical no-op and never
+    // surface the dialog.
+    const eventId = resolveEmptyEditDelete(
+      editTargetRef.current?.id,
+      Boolean(onDeleteEditTargetRef.current),
+    );
+    if (eventId !== null) {
+      setIsOpen(true);
+    }
+  }, [editTargetRef]);
+
+  const confirmDelete = React.useCallback(() => {
+    setIsOpen(false);
+    // Re-resolve at confirm time. The AlertDialog is modal, so the edit target
+    // can't change underneath it; if it somehow cleared, the null id no-ops.
+    const eventId = resolveEmptyEditDelete(
+      editTargetRef.current?.id,
+      Boolean(onDeleteEditTargetRef.current),
+    );
+    if (eventId === null) {
+      return;
+    }
+    clearComposerBodyRef.current();
+    void onDeleteEditTargetRef.current?.(eventId);
+  }, [editTargetRef]);
+
+  const dialog = (
+    <AlertDialog onOpenChange={setIsOpen} open={isOpen}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>Delete message?</AlertDialogTitle>
+          <AlertDialogDescription>
+            This will permanently delete this message and cannot be undone.
+          </AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel asChild>
+            <Button type="button" variant="outline">
+              Cancel
+            </Button>
+          </AlertDialogCancel>
+          <AlertDialogAction asChild>
+            <Button
+              data-testid="confirm-empty-edit-delete"
+              onClick={confirmDelete}
+              type="button"
+              variant="destructive"
+            >
+              Delete
+            </Button>
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  );
+
+  return {
+    requestEmptyEditDelete: requestDelete,
+    emptyEditDeleteDialog: dialog,
+  };
+}

From d52c8726befbdb8d417c5f86b6cba3ba20cf399a Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 15:10:31 -0700
Subject: [PATCH 2/7] refactor(desktop): empty-edit delete calls existing
 delete path, no duplicate dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Review feedback: the first cut added a second "Delete message?" AlertDialog
in a composer-side hook — a verbatim copy of the one already in
MessageActionBar. That duplicated the delete-confirmation UI.

Drop the dialog entirely. When an edit is cleared to empty and submitted,
the composer now calls the existing delete handler directly (the same
deleteMutate the "Delete message" button uses, via handleDeleteEditTarget,
which also exits edit mode) — no separate confirmation, since clearing a
message and pressing Enter is already a deliberate act. The tested pure
guard `resolveEmptyEditDelete` is kept, so an empty edit still no-ops when
no delete handler is wired and never publishes an empty body.

Removes useEmptyEditDelete.tsx (~120 lines). The useComposerInsertEmoji
extraction stays: MessageComposer.tsx is grandfathered over the 1000-line
ratchet (1027 on main), and that extraction is what keeps this change under
the frozen baseline (now 1019).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 .../features/messages/ui/MessageComposer.tsx  |  37 +++---
 .../features/messages/ui/emptyEditDelete.ts   |   5 +-
 .../messages/ui/useEmptyEditDelete.tsx        | 119 ------------------
 3 files changed, 20 insertions(+), 141 deletions(-)
 delete mode 100644 desktop/src/features/messages/ui/useEmptyEditDelete.tsx

diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index c451934c6b..52e0167d96 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -54,7 +54,7 @@ import { useMentionSendFlow } from "./useMentionSendFlow";
 import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
 import { useComposerContentState } from "./useComposerContentState";
 import { useComposerInsertEmoji } from "./useComposerInsertEmoji";
-import { useEmptyEditDelete } from "./useEmptyEditDelete";
+import { resolveEmptyEditDelete } from "./emptyEditDelete";
 import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
 
 import type { MessageComposerProps } from "./MessageComposer.types";
@@ -449,18 +449,6 @@ function MessageComposerImpl({
     },
   });
 
-  // ── Empty-edit → delete ─────────────────────────────────────────────
-  // Clearing an edit to empty and submitting is the keyboard shorthand for
-  // "Delete message"; the hook owns the confirmation dialog + delete handoff.
-  const { requestEmptyEditDelete, emptyEditDeleteDialog } = useEmptyEditDelete({
-    editTargetRef,
-    onDeleteEditTarget,
-    clearComposerBody: () => {
-      setComposerContent("");
-      richText.clearContent();
-    },
-  });
-
   // ── @ mention picker (toolbar button) ───────────────────────────────
   const openMentionPicker = React.useCallback(() => {
     if (!richText.editor) return;
@@ -502,11 +490,22 @@ function MessageComposerImpl({
       const currentPendingImeta = media.pendingImetaRef.current;
       const hasMedia = currentPendingImeta.length > 0;
       // Empty text + zero attachments: clearing an edit to nothing is the
-      // keyboard shorthand for deleting the message (confirm-then-delete via
-      // the hook). No-ops when no delete handler is wired, so an empty edit
-      // never silently publishes an empty body.
+      // keyboard shorthand for "Delete message" — call the existing delete
+      // path directly (same handler the Delete button uses; it exits edit
+      // mode). No extra confirmation: clearing a message and hitting Enter is
+      // already deliberate. `resolveEmptyEditDelete` keeps the historical
+      // no-op when no delete handler is wired, so an empty edit never
+      // publishes an empty body.
       if (!trimmed && !hasMedia) {
-        requestEmptyEditDelete();
+        const deleteTargetId = resolveEmptyEditDelete(
+          editTargetRef.current?.id,
+          Boolean(onDeleteEditTarget),
+        );
+        if (deleteTargetId) {
+          setComposerContent("");
+          richText.clearContent();
+          void onDeleteEditTarget?.(deleteTargetId);
+        }
         return;
       }
 
@@ -620,7 +619,7 @@ function MessageComposerImpl({
     mentionSendFlow.isPreparingMentionSend,
     mentionSendFlow.sendMessageWithMentionFlow,
     mentions.clearMentions,
-    requestEmptyEditDelete,
+    onDeleteEditTarget,
     richText.clearContent,
     richText.setContent,
     setComposerContent,
@@ -1001,8 +1000,6 @@ function MessageComposerImpl({
         </div>
       </footer>
 
-      {emptyEditDeleteDialog}
-
       <NonMemberMentionDialog
         error={mentionSendFlow.nonMemberPromptError}
         isInvitePending={mentionSendFlow.isInvitePending}
diff --git a/desktop/src/features/messages/ui/emptyEditDelete.ts b/desktop/src/features/messages/ui/emptyEditDelete.ts
index 3058276bc3..b49d41eaed 100644
--- a/desktop/src/features/messages/ui/emptyEditDelete.ts
+++ b/desktop/src/features/messages/ui/emptyEditDelete.ts
@@ -12,8 +12,9 @@
  * delete handler is wired. In the no-handler case an empty edit stays a no-op
  * rather than destroying anything, preserving the historical guard.
  *
- * Used at two moments with the same rule: deciding whether to surface the
- * confirmation on submit, and resolving the target when the user confirms.
+ * Called from the composer's submit path when an edit is cleared to empty: a
+ * non-null result is handed straight to the existing delete handler (the same
+ * one the "Delete message" button uses), with no separate confirmation UI.
  */
 export function resolveEmptyEditDelete(
   editTargetId: string | null | undefined,
diff --git a/desktop/src/features/messages/ui/useEmptyEditDelete.tsx b/desktop/src/features/messages/ui/useEmptyEditDelete.tsx
deleted file mode 100644
index 5e8c897e54..0000000000
--- a/desktop/src/features/messages/ui/useEmptyEditDelete.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import * as React from "react";
-
-import {
-  AlertDialog,
-  AlertDialogAction,
-  AlertDialogCancel,
-  AlertDialogContent,
-  AlertDialogDescription,
-  AlertDialogFooter,
-  AlertDialogHeader,
-  AlertDialogTitle,
-} from "@/shared/ui/alert-dialog";
-import { Button } from "@/shared/ui/button";
-
-import { resolveEmptyEditDelete } from "./emptyEditDelete";
-
-type EmptyEditDeleteParams = {
-  /** Live ref to the message currently loaded into the composer for editing. */
-  editTargetRef: React.RefObject<{ id: string } | null | undefined>;
-  /**
-   * Owner handler that deletes the edited message and exits edit mode. When
-   * undefined, clearing an edit to empty stays an inert no-op.
-   */
-  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
-  /** Empties the composer body so it doesn't linger after the delete. */
-  clearComposerBody: () => void;
-};
-
-/**
- * Turns "clear an edit to empty and submit" into the delete-message action.
- *
- * Deleting a message by right-click → "Delete message" pops a confirmation
- * before anything is destroyed; this mirrors that exactly. `requestDelete`
- * (called from the composer's submit guard when the edited body is empty and
- * has no attachments) opens the same confirmation, and only on confirm does it
- * clear the composer and hand the target id to the owner's delete handler. A
- * confirmation — rather than an instant delete — means an accidental
- * clear-and-Enter can't destroy a message with no undo.
- *
- * Returns the trigger plus the dialog element to render inside the composer.
- * No-ops (and never opens the dialog) when no delete handler is wired.
- */
-export function useEmptyEditDelete({
-  editTargetRef,
-  onDeleteEditTarget,
-  clearComposerBody,
-}: EmptyEditDeleteParams) {
-  const [isOpen, setIsOpen] = React.useState(false);
-
-  // Stash the owner handler + clear callback in refs so the returned callbacks
-  // stay reference-stable across renders (the composer feeds them into large
-  // dependency arrays and memoized children).
-  const onDeleteEditTargetRef = React.useRef(onDeleteEditTarget);
-  onDeleteEditTargetRef.current = onDeleteEditTarget;
-  const clearComposerBodyRef = React.useRef(clearComposerBody);
-  clearComposerBodyRef.current = clearComposerBody;
-
-  const requestDelete = React.useCallback(() => {
-    // No target / no delete handler → keep the historical no-op and never
-    // surface the dialog.
-    const eventId = resolveEmptyEditDelete(
-      editTargetRef.current?.id,
-      Boolean(onDeleteEditTargetRef.current),
-    );
-    if (eventId !== null) {
-      setIsOpen(true);
-    }
-  }, [editTargetRef]);
-
-  const confirmDelete = React.useCallback(() => {
-    setIsOpen(false);
-    // Re-resolve at confirm time. The AlertDialog is modal, so the edit target
-    // can't change underneath it; if it somehow cleared, the null id no-ops.
-    const eventId = resolveEmptyEditDelete(
-      editTargetRef.current?.id,
-      Boolean(onDeleteEditTargetRef.current),
-    );
-    if (eventId === null) {
-      return;
-    }
-    clearComposerBodyRef.current();
-    void onDeleteEditTargetRef.current?.(eventId);
-  }, [editTargetRef]);
-
-  const dialog = (
-    <AlertDialog onOpenChange={setIsOpen} open={isOpen}>
-      <AlertDialogContent>
-        <AlertDialogHeader>
-          <AlertDialogTitle>Delete message?</AlertDialogTitle>
-          <AlertDialogDescription>
-            This will permanently delete this message and cannot be undone.
-          </AlertDialogDescription>
-        </AlertDialogHeader>
-        <AlertDialogFooter>
-          <AlertDialogCancel asChild>
-            <Button type="button" variant="outline">
-              Cancel
-            </Button>
-          </AlertDialogCancel>
-          <AlertDialogAction asChild>
-            <Button
-              data-testid="confirm-empty-edit-delete"
-              onClick={confirmDelete}
-              type="button"
-              variant="destructive"
-            >
-              Delete
-            </Button>
-          </AlertDialogAction>
-        </AlertDialogFooter>
-      </AlertDialogContent>
-    </AlertDialog>
-  );
-
-  return {
-    requestEmptyEditDelete: requestDelete,
-    emptyEditDeleteDialog: dialog,
-  };
-}

From 9a86441dc3c57e9b4ca4b8b5f5ed57fd3a2c9995 Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 15:22:18 -0700
Subject: [PATCH 3/7] =?UTF-8?q?refactor(desktop):=20drop=20onDeleteEditTar?=
 =?UTF-8?q?get;=20branch=20empty=E2=86=92delete=20in=20handleEditSave?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Review feedback: the onDeleteEditTarget prop threaded through
ChannelScreen → ChannelPane → MessageComposer / MessageThreadPanel was
redundant. Both the main timeline and the thread panel already route
edit-save through the same handleEditSave, so the empty→delete decision
belongs there — a single decision point, no new prop chain.

handleEditSave now deletes the edit target (via the same deleteMutate the
"Delete message" button uses) when the submitted content is empty with no
media tags, instead of publishing an empty edit. The composer's empty-edit
branch just hands "" to onEditSave.

Removes the onDeleteEditTarget prop from MessageComposer, MessageThreadPanel,
ChannelPane, and their types; drops handleDeleteEditTarget; and deletes the
now-unused resolveEmptyEditDelete helper + test. Net −106/+27 this pass.
Image-only edits (empty text, media present) still publish normally — only
a fully empty edit deletes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 .../src/features/channels/ui/ChannelPane.tsx  |  3 --
 .../features/channels/ui/ChannelPane.types.ts |  1 -
 .../features/channels/ui/ChannelScreen.tsx    |  6 ----
 .../channels/useChannelPaneHandlers.ts        | 29 ++++++++++---------
 .../features/messages/ui/MessageComposer.tsx  | 24 ++++-----------
 .../messages/ui/MessageComposer.types.ts      | 14 ++++-----
 .../messages/ui/MessageThreadPanel.tsx        |  3 --
 .../messages/ui/emptyEditDelete.test.mjs      | 26 -----------------
 .../features/messages/ui/emptyEditDelete.ts   | 27 -----------------
 9 files changed, 27 insertions(+), 106 deletions(-)
 delete mode 100644 desktop/src/features/messages/ui/emptyEditDelete.test.mjs
 delete mode 100644 desktop/src/features/messages/ui/emptyEditDelete.ts

diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 55f5cea47e..70877875f7 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -113,7 +113,6 @@ export const ChannelPane = React.memo(function ChannelPane({
   onDelete,
   onEdit,
   onEditSave,
-  onDeleteEditTarget,
   onFollowThread,
   onMarkUnread,
   onMarkRead,
@@ -763,7 +762,6 @@ export const ChannelPane = React.memo(function ChannelPane({
                   onCancelEdit={onCancelEdit}
                   onEditLastOwnMessage={handleEditLastOwnMainMessage}
                   onEditSave={onEditSave}
-                  onDeleteEditTarget={onDeleteEditTarget}
                   onPrepareSendChannel={
                     activeChannel?.channelType === "dm"
                       ? prepareDmSendChannel
@@ -863,7 +861,6 @@ export const ChannelPane = React.memo(function ChannelPane({
                 onEdit={onEdit}
                 onEditLastOwnMessage={handleEditLastOwnThreadMessage}
                 onEditSave={onEditSave}
-                onDeleteEditTarget={onDeleteEditTarget}
                 onFollowThread={onFollowThread}
                 onMarkUnread={onMarkUnread}
                 onMarkRead={onMarkRead}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 2e59dcd5bc..7257d8cd55 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -88,7 +88,6 @@ export type ChannelPaneProps = {
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
-  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   onMarkUnread?: (message: TimelineMessage) => void;
   onMarkRead?: (message: TimelineMessage) => void;
   onExpandThreadReplies: (message: TimelineMessage) => void;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 441f162565..7b750daa4f 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -488,7 +488,6 @@ export function ChannelScreen({
     handleCancelThreadReply,
     handleCloseThread,
     handleDelete,
-    handleDeleteEditTarget,
     handleEdit,
     handleEditSave,
     handleExpandThreadReplies,
@@ -912,11 +911,6 @@ export function ChannelScreen({
                   onEditSave={
                     activeChannel?.archivedAt ? undefined : handleEditSave
                   }
-                  onDeleteEditTarget={
-                    activeChannel?.archivedAt
-                      ? undefined
-                      : handleDeleteEditTarget
-                  }
                   onMarkUnread={handleMessageMarkUnread}
                   onMarkRead={handleMessageMarkRead}
                   onExpandThreadReplies={handleExpandThreadReplies}
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index a1e2cd2a12..aded2c1376 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -133,18 +133,6 @@ export function useChannelPaneHandlers({
     await deleteMutateRef.current({ eventId: message.id }).catch(() => {});
   }, []);
 
-  // Delete the message currently being edited and leave edit mode. Fired when
-  // the user clears an edit to empty and confirms — the keyboard equivalent of
-  // the "Delete message" action. Exit edit mode first so the composer collapses
-  // immediately; the delete follows (its own onError toast surfaces failures).
-  const handleDeleteEditTarget = React.useCallback(
-    async (eventId: string) => {
-      setEditTargetId(null);
-      await deleteMutateRef.current({ eventId }).catch(() => {});
-    },
-    [setEditTargetId],
-  );
-
   const handleEdit = React.useCallback(
     (message: { id: string }) => {
       setEditTargetId((current) =>
@@ -166,6 +154,22 @@ export function useChannelPaneHandlers({
         return;
       }
 
+      // Clearing an edit to empty (no text, no attachments) is the keyboard
+      // shorthand for "Delete message": delete the message instead of
+      // publishing an empty edit — the same `deleteMutate` the Delete button
+      // uses. This is the single decision point for both the main timeline and
+      // the thread panel, since both route edit-save through here. Exit edit
+      // mode first so the composer collapses immediately; the delete's own
+      // onError toast surfaces failures.
+      const isEmptyDeletion =
+        content.trim().length === 0 &&
+        (mediaTags === undefined || mediaTags.length === 0);
+      if (isEmptyDeletion) {
+        setEditTargetId(null);
+        await deleteMutateRef.current({ eventId }).catch(() => {});
+        return;
+      }
+
       await editMutateRef.current({
         eventId,
         content,
@@ -353,7 +357,6 @@ export function useChannelPaneHandlers({
     handleCancelThreadReply,
     handleCloseThread,
     handleDelete,
-    handleDeleteEditTarget,
     handleEdit,
     handleEditSave,
     handleExpandThreadReplies,
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 52e0167d96..30845173c2 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -54,7 +54,6 @@ import { useMentionSendFlow } from "./useMentionSendFlow";
 import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
 import { useComposerContentState } from "./useComposerContentState";
 import { useComposerInsertEmoji } from "./useComposerInsertEmoji";
-import { resolveEmptyEditDelete } from "./emptyEditDelete";
 import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
 
 import type { MessageComposerProps } from "./MessageComposer.types";
@@ -77,7 +76,6 @@ function MessageComposerImpl({
   onCaptureSendContext,
   onEditLastOwnMessage,
   onEditSave,
-  onDeleteEditTarget,
   onPrepareSendChannel,
   onPreparingMentionSendChange,
   onSend,
@@ -490,22 +488,13 @@ function MessageComposerImpl({
       const currentPendingImeta = media.pendingImetaRef.current;
       const hasMedia = currentPendingImeta.length > 0;
       // Empty text + zero attachments: clearing an edit to nothing is the
-      // keyboard shorthand for "Delete message" — call the existing delete
-      // path directly (same handler the Delete button uses; it exits edit
-      // mode). No extra confirmation: clearing a message and hitting Enter is
-      // already deliberate. `resolveEmptyEditDelete` keeps the historical
-      // no-op when no delete handler is wired, so an empty edit never
-      // publishes an empty body.
+      // keyboard shorthand for "Delete message". Hand empty content to
+      // onEditSave, which deletes the message instead of publishing an empty
+      // body (see handleEditSave). Nothing to build, so short-circuit here.
       if (!trimmed && !hasMedia) {
-        const deleteTargetId = resolveEmptyEditDelete(
-          editTargetRef.current?.id,
-          Boolean(onDeleteEditTarget),
-        );
-        if (deleteTargetId) {
-          setComposerContent("");
-          richText.clearContent();
-          void onDeleteEditTarget?.(deleteTargetId);
-        }
+        setComposerContent("");
+        richText.clearContent();
+        void onEditSaveRef.current("", [], []);
         return;
       }
 
@@ -619,7 +608,6 @@ function MessageComposerImpl({
     mentionSendFlow.isPreparingMentionSend,
     mentionSendFlow.sendMessageWithMentionFlow,
     mentions.clearMentions,
-    onDeleteEditTarget,
     richText.clearContent,
     richText.setContent,
     setComposerContent,
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts
index 3039be369a..d888db5da5 100644
--- a/desktop/src/features/messages/ui/MessageComposer.types.ts
+++ b/desktop/src/features/messages/ui/MessageComposer.types.ts
@@ -62,20 +62,16 @@ export type MessageComposerProps = {
    * return `false` to let the arrow key fall through normally.
    */
   onEditLastOwnMessage?: () => boolean;
+  /**
+   * Saves the edited message. Empty `content` with no media tags is the
+   * keyboard shorthand for "Delete message": the handler deletes the message
+   * instead of publishing an empty body (so an empty edit never ships).
+   */
   onEditSave?: (
     content: string,
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
-  /**
-   * Invoked when the user submits an edit whose body has been cleared to empty
-   * (no text and no attachments). Clearing an edit to nothing is the keyboard
-   * shorthand for deleting the message: the composer surfaces the same
-   * confirmation the "Delete message" action uses and only calls this after
-   * the user confirms. The owner should delete the message identified by
-   * `eventId` and exit edit mode. When omitted, an empty edit stays a no-op.
-   */
-  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   /** Captures send context synchronously before awaits can change navigation. */
   onCaptureSendContext?: () => {
     parentEventId: string | null;
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index ee5076eb2a..6234af22d1 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -77,7 +77,6 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
     mediaTags?: string[][],
     mentionPubkeys?: string[],
   ) => Promise<void>;
-  onDeleteEditTarget?: (eventId: string) => void | Promise<void>;
   onMarkUnread?: (message: TimelineMessage) => void;
   onMarkRead?: (message: TimelineMessage) => void;
   onExpandReplies: (message: TimelineMessage) => void;
@@ -207,7 +206,6 @@ export function MessageThreadPanel({
   onEdit,
   onEditLastOwnMessage,
   onEditSave,
-  onDeleteEditTarget,
   onFollowThread,
   onMarkUnread,
   onMarkRead,
@@ -879,7 +877,6 @@ export function MessageThreadPanel({
               onCaptureSendContext={onCaptureSendContext}
               onEditLastOwnMessage={onEditLastOwnMessage}
               onEditSave={onEditSave}
-              onDeleteEditTarget={onDeleteEditTarget}
               onSend={onSend}
               placeholder={`Reply in thread to ${threadHead.author}`}
               profiles={profiles}
diff --git a/desktop/src/features/messages/ui/emptyEditDelete.test.mjs b/desktop/src/features/messages/ui/emptyEditDelete.test.mjs
deleted file mode 100644
index 12093ee2de..0000000000
--- a/desktop/src/features/messages/ui/emptyEditDelete.test.mjs
+++ /dev/null
@@ -1,26 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { resolveEmptyEditDelete } from "./emptyEditDelete.ts";
-
-test("deletes the edited message when a handler is wired", () => {
-  assert.equal(resolveEmptyEditDelete("evt-123", true), "evt-123");
-});
-
-test("no-op when no delete handler is wired (never destroys silently)", () => {
-  assert.equal(resolveEmptyEditDelete("evt-123", false), null);
-});
-
-test("no-op when there is no message loaded for editing", () => {
-  assert.equal(resolveEmptyEditDelete(null, true), null);
-  assert.equal(resolveEmptyEditDelete(undefined, true), null);
-});
-
-test("no-op on a blank target id", () => {
-  assert.equal(resolveEmptyEditDelete("", true), null);
-});
-
-test("requires both a target and a handler", () => {
-  assert.equal(resolveEmptyEditDelete(null, false), null);
-  assert.equal(resolveEmptyEditDelete("", false), null);
-});
diff --git a/desktop/src/features/messages/ui/emptyEditDelete.ts b/desktop/src/features/messages/ui/emptyEditDelete.ts
deleted file mode 100644
index b49d41eaed..0000000000
--- a/desktop/src/features/messages/ui/emptyEditDelete.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Pure decision logic for the "clear an edit to empty = delete the message"
- * composer shortcut. Kept free of React so the rule can be unit-tested
- * directly.
- */
-
-/**
- * Resolve which message a submitted empty edit should delete.
- *
- * Returns the event id to delete, or `null` when the deletion must not
- * proceed — either no message is loaded for editing (blank/absent id) or no
- * delete handler is wired. In the no-handler case an empty edit stays a no-op
- * rather than destroying anything, preserving the historical guard.
- *
- * Called from the composer's submit path when an edit is cleared to empty: a
- * non-null result is handed straight to the existing delete handler (the same
- * one the "Delete message" button uses), with no separate confirmation UI.
- */
-export function resolveEmptyEditDelete(
-  editTargetId: string | null | undefined,
-  hasDeleteHandler: boolean,
-): string | null {
-  if (!hasDeleteHandler || !editTargetId) {
-    return null;
-  }
-  return editTargetId;
-}

From f6a116382411850fd8c89ba25ec1296897f3db8c Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 15:34:44 -0700
Subject: [PATCH 4/7] refactor(desktop): delete empty-edit guard, drop
 unrelated emoji split

The empty->delete behavior needs no special-case branch in the composer:
the old edit path had a guard (`if (!trimmed && !hasMedia) return;`) that
*blocked* empty edits. Removing that guard lets empty content flow through
buildOutgoingMessage("") -> onEditSave("", [], []) -> handleEditSave, which
already deletes on empty content. Net-negative change to the composer.

That removes the reason the emoji-insertion logic was extracted into
useComposerInsertEmoji.ts (only ever done to claw back lines under the
file-size ratchet). Reverted that extraction entirely so this PR touches
nothing emoji-related; MessageComposer.tsx sits at 1026, under its 1027
grandfathered baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 .../features/messages/ui/MessageComposer.tsx  | 53 +++++++++++-----
 .../messages/ui/useComposerInsertEmoji.ts     | 62 -------------------
 2 files changed, 36 insertions(+), 79 deletions(-)
 delete mode 100644 desktop/src/features/messages/ui/useComposerInsertEmoji.ts

diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 30845173c2..69e4ec67b5 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -30,6 +30,7 @@ import {
   hasMentionClipboardHtml,
   normalizeMentionClipboardHtml,
 } from "@/features/messages/lib/normalizeMentionClipboard";
+import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
 import {
   type AutocompleteEdit,
   type LinkSelectionInfo,
@@ -53,7 +54,6 @@ import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
 import { useMentionSendFlow } from "./useMentionSendFlow";
 import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
 import { useComposerContentState } from "./useComposerContentState";
-import { useComposerInsertEmoji } from "./useComposerInsertEmoji";
 import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
 
 import type { MessageComposerProps } from "./MessageComposer.types";
@@ -438,14 +438,41 @@ function MessageComposerImpl({
   );
 
   // ── Emoji insertion ─────────────────────────────────────────────────
-  const insertEmoji = useComposerInsertEmoji({
-    editor: richText.editor,
-    customEmoji,
-    onAfterInsert: () => {
+  const insertEmoji = React.useCallback(
+    (emoji: string) => {
+      if (!richText.editor) return;
+      // A `:shortcode:` for a known custom emoji becomes a selectable atom
+      // node (same as the input rule / autocomplete), so it can be selected,
+      // copied, and deleted as one unit. Everything else (native unicode)
+      // inserts as plain content.
+      const match = /^:([^:\s]+):$/.exec(emoji);
+      const shortcode = match?.[1]?.toLowerCase();
+      const known =
+        shortcode &&
+        customEmoji.some((e) => e.shortcode.toLowerCase() === shortcode);
+      if (known && shortcode) {
+        richText.editor
+          .chain()
+          .focus()
+          .insertContent({
+            type: CUSTOM_EMOJI_NODE_NAME,
+            attrs: {
+              shortcode,
+              src:
+                customEmoji.find((e) => e.shortcode.toLowerCase() === shortcode)
+                  ?.url ?? "",
+            },
+          })
+          .insertContent(" ")
+          .run();
+      } else {
+        richText.editor.chain().focus().insertContent(emoji).run();
+      }
       setIsEmojiPickerOpen(false);
       mentions.clearMentions();
     },
-  });
+    [richText.editor, mentions.clearMentions, customEmoji],
+  );
 
   // ── @ mention picker (toolbar button) ───────────────────────────────
   const openMentionPicker = React.useCallback(() => {
@@ -486,17 +513,9 @@ function MessageComposerImpl({
     if (editTargetRef.current && onEditSaveRef.current) {
       if (isSendingRef.current || isUploadingRef.current) return;
       const currentPendingImeta = media.pendingImetaRef.current;
-      const hasMedia = currentPendingImeta.length > 0;
-      // Empty text + zero attachments: clearing an edit to nothing is the
-      // keyboard shorthand for "Delete message". Hand empty content to
-      // onEditSave, which deletes the message instead of publishing an empty
-      // body (see handleEditSave). Nothing to build, so short-circuit here.
-      if (!trimmed && !hasMedia) {
-        setComposerContent("");
-        richText.clearContent();
-        void onEditSaveRef.current("", [], []);
-        return;
-      }
+      // No empty-edit guard here: clearing an edit to empty (no text, no
+      // attachments) flows through to onEditSave as empty content, which
+      // deletes the message instead of publishing it (see handleEditSave).
 
       // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []`
       // because edit semantics use `[]` as the explicit "wipe all
diff --git a/desktop/src/features/messages/ui/useComposerInsertEmoji.ts b/desktop/src/features/messages/ui/useComposerInsertEmoji.ts
deleted file mode 100644
index e99ce3fa17..0000000000
--- a/desktop/src/features/messages/ui/useComposerInsertEmoji.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-
-import type { Editor } from "@tiptap/react";
-
-import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
-import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
-
-type InsertEmojiParams = {
-  editor: Editor | null;
-  customEmoji: CustomEmoji[];
-  /** Side effects to run after an insert (close the picker, clear mentions). */
-  onAfterInsert: () => void;
-};
-
-/**
- * Insert an emoji into the composer from the toolbar emoji picker.
- *
- * A `:shortcode:` for a known custom emoji becomes a selectable atom node (same
- * as the input rule / autocomplete) so it can be selected, copied, and deleted
- * as one unit; everything else (native unicode) inserts as plain content.
- */
-export function useComposerInsertEmoji({
-  editor,
-  customEmoji,
-  onAfterInsert,
-}: InsertEmojiParams) {
-  // Keep the post-insert side effects in a ref so the returned callback's
-  // identity tracks only editor/customEmoji, exactly as it did inline.
-  const onAfterInsertRef = React.useRef(onAfterInsert);
-  onAfterInsertRef.current = onAfterInsert;
-
-  return React.useCallback(
-    (emoji: string) => {
-      if (!editor) return;
-      const match = /^:([^:\s]+):$/.exec(emoji);
-      const shortcode = match?.[1]?.toLowerCase();
-      const known =
-        shortcode &&
-        customEmoji.some((e) => e.shortcode.toLowerCase() === shortcode);
-      if (known && shortcode) {
-        editor
-          .chain()
-          .focus()
-          .insertContent({
-            type: CUSTOM_EMOJI_NODE_NAME,
-            attrs: {
-              shortcode,
-              src:
-                customEmoji.find((e) => e.shortcode.toLowerCase() === shortcode)
-                  ?.url ?? "",
-            },
-          })
-          .insertContent(" ")
-          .run();
-      } else {
-        editor.chain().focus().insertContent(emoji).run();
-      }
-      onAfterInsertRef.current();
-    },
-    [editor, customEmoji],
-  );
-}

From 1fc37a106440975949f6834636a2220939b8ba7a Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 15:40:28 -0700
Subject: [PATCH 5/7] test(desktop): e2e for empty-edit delete; drop misplaced
 prop comment

Add tests/e2e/empty-edit-delete.spec.ts covering the feature end to end on
the mock identity's own #general message: clearing an edit to empty deletes
the row (no confirmation dialog), and a non-empty edit still edits and never
deletes. Registered in the smoke project.

Drop the onEditSave doc block on MessageComposer.types.ts: it described the
handler's empty->delete behavior on the prop interface, which is the wrong
home (a different onEditSave impl need not delete on empty) and duplicated the
comment at the composer call site. Returns the types file to baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 desktop/playwright.config.ts                  |  1 +
 .../messages/ui/MessageComposer.types.ts      |  5 --
 desktop/tests/e2e/empty-edit-delete.spec.ts   | 88 +++++++++++++++++++
 3 files changed, 89 insertions(+), 5 deletions(-)
 create mode 100644 desktop/tests/e2e/empty-edit-delete.spec.ts

diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index b86406d9b0..bba9218a1a 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -95,6 +95,7 @@ export default defineConfig({
         "**/cold-switch-longtask.perf.ts",
         "**/timeline-no-shift.spec.ts",
         "**/human-edit-agent-content.spec.ts",
+        "**/empty-edit-delete.spec.ts",
         "**/reaction-order.spec.ts",
         "**/reaction-names.spec.ts",
         "**/inbox-reactions.spec.ts",
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts
index d888db5da5..800474c3dd 100644
--- a/desktop/src/features/messages/ui/MessageComposer.types.ts
+++ b/desktop/src/features/messages/ui/MessageComposer.types.ts
@@ -62,11 +62,6 @@ export type MessageComposerProps = {
    * return `false` to let the arrow key fall through normally.
    */
   onEditLastOwnMessage?: () => boolean;
-  /**
-   * Saves the edited message. Empty `content` with no media tags is the
-   * keyboard shorthand for "Delete message": the handler deletes the message
-   * instead of publishing an empty body (so an empty edit never ships).
-   */
   onEditSave?: (
     content: string,
     mediaTags?: string[][],
diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts
new file mode 100644
index 0000000000..40f1d59707
--- /dev/null
+++ b/desktop/tests/e2e/empty-edit-delete.spec.ts
@@ -0,0 +1,88 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+// The mock identity's own pre-seeded message in #general (authored by
+// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own
+// message is exactly Sam's workflow: "delete a message by clearing its edit."
+const OWN_MESSAGE_ID = "mock-general-welcome";
+const ORIGINAL_CONTENT = "Welcome to #general";
+
+// Open the more-actions menu for a message row and wait for the menu to mount.
+async function openMoreActionsMenu(
+  page: import("@playwright/test").Page,
+  messageId: string,
+) {
+  const row = page.locator(`[data-message-id="${messageId}"]`);
+  await row.hover();
+  await page.getByTestId(`more-actions-${messageId}`).click();
+  await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
+    timeout: 5_000,
+  });
+}
+
+// Enter edit mode for a message and wait until the editor is populated.
+async function enterEditMode(
+  page: import("@playwright/test").Page,
+  messageId: string,
+) {
+  await openMoreActionsMenu(page, messageId);
+  await page.getByTestId(`edit-message-${messageId}`).click();
+  await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
+  // Edit mode sets the editor content via Tiptap's async transaction pipeline;
+  // wait for it to populate before we clear it.
+  const input = page.getByTestId("message-input");
+  await expect(input).not.toBeEmpty({ timeout: 5_000 });
+  return input;
+}
+
+test.beforeEach(async ({ page }) => {
+  await installMockBridge(page);
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+});
+
+test("clearing an edit to empty deletes the message", async ({ page }) => {
+  const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
+  await expect(row).toBeVisible({ timeout: 10_000 });
+
+  const input = await enterEditMode(page, OWN_MESSAGE_ID);
+
+  // Clear the whole message, then submit the (now empty) edit.
+  await input.click();
+  await page.keyboard.press("ControlOrMeta+A");
+  await page.keyboard.press("Backspace");
+  await expect(input).toBeEmpty();
+  await page.keyboard.press("Enter");
+
+  // Empty-edit delete is immediate — no confirmation dialog (unlike the
+  // explicit "Delete message" button). Edit mode exits and the row is gone.
+  await expect(page.getByRole("alertdialog")).toHaveCount(0);
+  await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 });
+  await expect(row).toBeHidden({ timeout: 5_000 });
+});
+
+test("a non-empty edit still edits and never deletes", async ({ page }) => {
+  const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
+  await expect(row).toBeVisible({ timeout: 10_000 });
+
+  const input = await enterEditMode(page, OWN_MESSAGE_ID);
+  const editedContent = `Edited, not deleted ${Date.now()}`;
+
+  await input.click();
+  await page.keyboard.press("ControlOrMeta+A");
+  await page.keyboard.type(editedContent);
+  await page.keyboard.press("Enter");
+
+  // Edit mode exits, the row survives, and its content is the new text — the
+  // empty-delete branch must not fire when there is still text.
+  await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 });
+  await expect(row).toBeVisible();
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    editedContent,
+  );
+  await expect(page.getByTestId("message-timeline")).not.toContainText(
+    ORIGINAL_CONTENT,
+  );
+});

From 6774b43dd2f263b73536ebf202f0f69de33876dc Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 16:13:40 -0700
Subject: [PATCH 6/7] feat(desktop): empty-edit delete routes through the
 Delete confirmation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Per review, an empty-edit delete should not fire silently — it should show
the same "Delete message?" confirmation the Delete menu action does.

Extract that confirmation into a shared DeleteMessageConfirmDialog (single
definition, no duplication) and use it in two places:
- MessageActionBar renders it for the Delete menu action (was inline).
- handleEditSave, on an empty edit, exits edit mode and asks ChannelScreen to
  open the same dialog via onRequestEmptyEditDelete; Delete runs the existing
  deleteMutate, Cancel leaves the message untouched.

E2E updated: empty edit prompts then deletes on confirm, survives on cancel;
a non-empty edit still edits with no prompt. 3 specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 .../features/channels/ui/ChannelScreen.tsx    | 17 +++++
 .../channels/useChannelPaneHandlers.ts        | 18 +++---
 .../ui/DeleteMessageConfirmDialog.tsx         | 53 +++++++++++++++
 .../features/messages/ui/MessageActionBar.tsx | 41 ++----------
 desktop/tests/e2e/empty-edit-delete.spec.ts   | 64 +++++++++++++------
 5 files changed, 130 insertions(+), 63 deletions(-)
 create mode 100644 desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx

diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 7b750daa4f..01f40a7540 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -48,6 +48,7 @@ import {
   channelWindowThreadSummaries,
   type ChannelWindowThreadSummary,
 } from "@/features/messages/lib/channelWindowStore";
+import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
 import { getThreadReference } from "@/features/messages/lib/threading";
 import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
 import {
@@ -483,6 +484,9 @@ export function ChannelScreen({
       timelineMessages.find((message) => message.id === editTargetId) ?? null,
     [editTargetId, timelineMessages],
   );
+  // Event id awaiting the empty-edit "Delete message?" confirmation (non-null
+  // while the dialog is open); see handleEditSave.
+  const [emptyDeleteId, setEmptyDeleteId] = React.useState<string | null>(null);
   const {
     handleCancelEdit,
     handleCancelThreadReply,
@@ -506,6 +510,7 @@ export function ChannelScreen({
     markRevealedRepliesRead,
     openThreadHeadId: effectiveOpenThreadHeadId,
     onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
+    onRequestEmptyEditDelete: setEmptyDeleteId,
     sendMessageMutation,
     setExpandedThreadReplyIds,
     setEditTargetId,
@@ -802,6 +807,18 @@ export function ChannelScreen({
           open={welcomeAgentCreate.isOpen}
           sendError={welcomeAgentCreate.error}
         />
+        <DeleteMessageConfirmDialog
+          onConfirm={() => {
+            if (emptyDeleteId) {
+              void handleDelete({ id: emptyDeleteId });
+            }
+            setEmptyDeleteId(null);
+          }}
+          onOpenChange={(open) => {
+            if (!open) setEmptyDeleteId(null);
+          }}
+          open={emptyDeleteId !== null}
+        />
         <div
           className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
           ref={channelContentRef}
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index aded2c1376..922917a62b 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -25,6 +25,7 @@ export function useChannelPaneHandlers({
   getReplyDescendantIdsForMessage,
   markRevealedRepliesRead,
   onOptimisticOpenThreadHeadIdChange,
+  onRequestEmptyEditDelete,
   openThreadHeadId,
   sendMessageMutation,
   setExpandedThreadReplyIds,
@@ -45,6 +46,7 @@ export function useChannelPaneHandlers({
   onOptimisticOpenThreadHeadIdChange: React.Dispatch<
     React.SetStateAction<string | null | undefined>
   >;
+  onRequestEmptyEditDelete: (eventId: string) => void;
   openThreadHeadId: string | null;
   sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
   setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
@@ -155,18 +157,18 @@ export function useChannelPaneHandlers({
       }
 
       // Clearing an edit to empty (no text, no attachments) is the keyboard
-      // shorthand for "Delete message": delete the message instead of
-      // publishing an empty edit — the same `deleteMutate` the Delete button
-      // uses. This is the single decision point for both the main timeline and
-      // the thread panel, since both route edit-save through here. Exit edit
-      // mode first so the composer collapses immediately; the delete's own
-      // onError toast surfaces failures.
+      // shorthand for "Delete message". Rather than publish an empty edit,
+      // route it through the same "Delete message?" confirmation the Delete
+      // button shows: exit edit mode and ask the pane to open the dialog, which
+      // runs the actual delete on confirm. This is the single decision point
+      // for both the main timeline and the thread panel, since both route
+      // edit-save through here.
       const isEmptyDeletion =
         content.trim().length === 0 &&
         (mediaTags === undefined || mediaTags.length === 0);
       if (isEmptyDeletion) {
         setEditTargetId(null);
-        await deleteMutateRef.current({ eventId }).catch(() => {});
+        onRequestEmptyEditDelete(eventId);
         return;
       }
 
@@ -178,7 +180,7 @@ export function useChannelPaneHandlers({
       });
       setEditTargetId(null);
     },
-    [setEditTargetId],
+    [onRequestEmptyEditDelete, setEditTargetId],
   );
 
   const handleOpenThread = React.useCallback(
diff --git a/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx b/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx
new file mode 100644
index 0000000000..9802f09efc
--- /dev/null
+++ b/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx
@@ -0,0 +1,53 @@
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import { Button } from "@/shared/ui/button";
+
+/**
+ * The "Delete message?" confirmation. Single definition shared by every
+ * surface that deletes a message — the message action menu (MessageActionBar)
+ * and the empty-edit delete path (clearing an edit to empty and hitting accept
+ * routes here, so it prompts exactly like the menu's Delete does). `onConfirm`
+ * fires when the user presses Delete; the caller owns the actual deletion.
+ */
+export function DeleteMessageConfirmDialog({
+  open,
+  onOpenChange,
+  onConfirm,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onConfirm: () => void;
+}) {
+  return (
+    <AlertDialog onOpenChange={onOpenChange} open={open}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>Delete message?</AlertDialogTitle>
+          <AlertDialogDescription>
+            This will permanently delete this message and cannot be undone.
+          </AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel asChild>
+            <Button type="button" variant="outline">
+              Cancel
+            </Button>
+          </AlertDialogCancel>
+          <AlertDialogAction asChild>
+            <Button onClick={onConfirm} type="button" variant="destructive">
+              Delete
+            </Button>
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  );
+}
diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx
index ba45bc62fb..967e50f5d2 100644
--- a/desktop/src/features/messages/ui/MessageActionBar.tsx
+++ b/desktop/src/features/messages/ui/MessageActionBar.tsx
@@ -35,17 +35,8 @@ import { copyTextToClipboard } from "@/shared/lib/clipboard";
 import { emojiDisplayName } from "@/shared/lib/emojiName";
 import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
 import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
-import {
-  AlertDialog,
-  AlertDialogAction,
-  AlertDialogCancel,
-  AlertDialogContent,
-  AlertDialogDescription,
-  AlertDialogFooter,
-  AlertDialogHeader,
-  AlertDialogTitle,
-} from "@/shared/ui/alert-dialog";
 import { Button } from "@/shared/ui/button";
+import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog";
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -277,35 +268,11 @@ function MoreActionsMenu({
       </DropdownMenu>
 
       {onDelete ? (
-        <AlertDialog
+        <DeleteMessageConfirmDialog
+          onConfirm={() => onDelete(message)}
           onOpenChange={setIsDeleteDialogOpen}
           open={isDeleteDialogOpen}
-        >
-          <AlertDialogContent>
-            <AlertDialogHeader>
-              <AlertDialogTitle>Delete message?</AlertDialogTitle>
-              <AlertDialogDescription>
-                This will permanently delete this message and cannot be undone.
-              </AlertDialogDescription>
-            </AlertDialogHeader>
-            <AlertDialogFooter>
-              <AlertDialogCancel asChild>
-                <Button type="button" variant="outline">
-                  Cancel
-                </Button>
-              </AlertDialogCancel>
-              <AlertDialogAction asChild>
-                <Button
-                  onClick={() => onDelete(message)}
-                  type="button"
-                  variant="destructive"
-                >
-                  Delete
-                </Button>
-              </AlertDialogAction>
-            </AlertDialogFooter>
-          </AlertDialogContent>
-        </AlertDialog>
+        />
       ) : null}
 
       {canReport ? (
diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts
index 40f1d59707..ee4cfc57fa 100644
--- a/desktop/tests/e2e/empty-edit-delete.spec.ts
+++ b/desktop/tests/e2e/empty-edit-delete.spec.ts
@@ -21,8 +21,9 @@ async function openMoreActionsMenu(
   });
 }
 
-// Enter edit mode for a message and wait until the editor is populated.
-async function enterEditMode(
+// Enter edit mode for a message, clear it to empty, and submit — the gesture
+// that triggers the empty-edit delete confirmation.
+async function submitEmptyEdit(
   page: import("@playwright/test").Page,
   messageId: string,
 ) {
@@ -33,7 +34,11 @@ async function enterEditMode(
   // wait for it to populate before we clear it.
   const input = page.getByTestId("message-input");
   await expect(input).not.toBeEmpty({ timeout: 5_000 });
-  return input;
+  await input.click();
+  await page.keyboard.press("ControlOrMeta+A");
+  await page.keyboard.press("Backspace");
+  await expect(input).toBeEmpty();
+  await page.keyboard.press("Enter");
 }
 
 test.beforeEach(async ({ page }) => {
@@ -43,31 +48,54 @@ test.beforeEach(async ({ page }) => {
   await expect(page.getByTestId("chat-title")).toHaveText("general");
 });
 
-test("clearing an edit to empty deletes the message", async ({ page }) => {
+test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({
+  page,
+}) => {
   const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
   await expect(row).toBeVisible({ timeout: 10_000 });
 
-  const input = await enterEditMode(page, OWN_MESSAGE_ID);
+  await submitEmptyEdit(page, OWN_MESSAGE_ID);
 
-  // Clear the whole message, then submit the (now empty) edit.
-  await input.click();
-  await page.keyboard.press("ControlOrMeta+A");
-  await page.keyboard.press("Backspace");
-  await expect(input).toBeEmpty();
-  await page.keyboard.press("Enter");
+  // The same "Delete message?" confirmation the Delete menu action shows — an
+  // empty edit is routed through it, not silently deleted.
+  const dialog = page.getByRole("alertdialog");
+  await expect(dialog).toBeVisible({ timeout: 5_000 });
+  await expect(dialog).toContainText("Delete message?");
 
-  // Empty-edit delete is immediate — no confirmation dialog (unlike the
-  // explicit "Delete message" button). Edit mode exits and the row is gone.
-  await expect(page.getByRole("alertdialog")).toHaveCount(0);
-  await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 });
+  // Confirm → the message row is removed and edit mode has exited.
+  await dialog.getByRole("button", { name: "Delete" }).click();
+  await expect(dialog).toBeHidden({ timeout: 5_000 });
+  await expect(page.getByTestId("edit-target")).toBeHidden();
   await expect(row).toBeHidden({ timeout: 5_000 });
 });
 
+test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
+  const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
+  await expect(row).toBeVisible({ timeout: 10_000 });
+
+  await submitEmptyEdit(page, OWN_MESSAGE_ID);
+
+  const dialog = page.getByRole("alertdialog");
+  await expect(dialog).toBeVisible({ timeout: 5_000 });
+
+  // Cancel → nothing is deleted; the original message survives.
+  await dialog.getByRole("button", { name: "Cancel" }).click();
+  await expect(dialog).toBeHidden({ timeout: 5_000 });
+  await expect(row).toBeVisible();
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    ORIGINAL_CONTENT,
+  );
+});
+
 test("a non-empty edit still edits and never deletes", async ({ page }) => {
   const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
   await expect(row).toBeVisible({ timeout: 10_000 });
 
-  const input = await enterEditMode(page, OWN_MESSAGE_ID);
+  await openMoreActionsMenu(page, OWN_MESSAGE_ID);
+  await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
+  await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
+  const input = page.getByTestId("message-input");
+  await expect(input).not.toBeEmpty({ timeout: 5_000 });
   const editedContent = `Edited, not deleted ${Date.now()}`;
 
   await input.click();
@@ -75,8 +103,8 @@ test("a non-empty edit still edits and never deletes", async ({ page }) => {
   await page.keyboard.type(editedContent);
   await page.keyboard.press("Enter");
 
-  // Edit mode exits, the row survives, and its content is the new text — the
-  // empty-delete branch must not fire when there is still text.
+  // No delete confirmation, edit mode exits, the row survives with new text.
+  await expect(page.getByRole("alertdialog")).toHaveCount(0);
   await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 });
   await expect(row).toBeVisible();
   await expect(page.getByTestId("message-timeline")).toContainText(

From eed08424877868705ace6654b88aa6a41a4f6fff Mon Sep 17 00:00:00 2001
From: Sam Westerman <swesterman@squareup.com>
Date: Thu, 30 Jul 2026 17:24:46 -0700
Subject: [PATCH 7/7] fix(desktop): keep edit mode active until empty-edit
 delete is confirmed

Per PR review: previously handleEditSave cleared the edit target before
opening the confirmation, so Cancel exited edit mode and discarded the
editing session. Now the empty branch leaves edit mode active while the
"Delete message?" dialog is open; edit mode is exited only when deletion is
confirmed (in ChannelScreen's onConfirm). Cancel returns the user to the
editor, restoring the pre-change resume-editing behavior.

E2E updated: assert edit mode stays active while the dialog is open and after
Cancel, and exits on confirm. 3 specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
---
 desktop/src/features/channels/ui/ChannelScreen.tsx      | 1 +
 desktop/src/features/channels/useChannelPaneHandlers.ts | 8 ++++----
 desktop/tests/e2e/empty-edit-delete.spec.ts             | 6 +++++-
 3 files changed, 10 insertions(+), 5 deletions(-)

diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 01f40a7540..b4d33b4776 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -810,6 +810,7 @@ export function ChannelScreen({
         <DeleteMessageConfirmDialog
           onConfirm={() => {
             if (emptyDeleteId) {
+              setEditTargetId(null);
               void handleDelete({ id: emptyDeleteId });
             }
             setEmptyDeleteId(null);
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index 922917a62b..53170257a4 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -159,15 +159,15 @@ export function useChannelPaneHandlers({
       // Clearing an edit to empty (no text, no attachments) is the keyboard
       // shorthand for "Delete message". Rather than publish an empty edit,
       // route it through the same "Delete message?" confirmation the Delete
-      // button shows: exit edit mode and ask the pane to open the dialog, which
-      // runs the actual delete on confirm. This is the single decision point
-      // for both the main timeline and the thread panel, since both route
+      // button shows. Keep edit mode active while the dialog is open so Cancel
+      // returns the user to the editor; edit mode is exited only once the
+      // deletion is confirmed (see ChannelScreen's onConfirm). Single decision
+      // point for both the main timeline and thread panel — both route
       // edit-save through here.
       const isEmptyDeletion =
         content.trim().length === 0 &&
         (mediaTags === undefined || mediaTags.length === 0);
       if (isEmptyDeletion) {
-        setEditTargetId(null);
         onRequestEmptyEditDelete(eventId);
         return;
       }
diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts
index ee4cfc57fa..772506571b 100644
--- a/desktop/tests/e2e/empty-edit-delete.spec.ts
+++ b/desktop/tests/e2e/empty-edit-delete.spec.ts
@@ -61,6 +61,8 @@ test("clearing an edit to empty prompts to delete, then deletes on confirm", asy
   const dialog = page.getByRole("alertdialog");
   await expect(dialog).toBeVisible({ timeout: 5_000 });
   await expect(dialog).toContainText("Delete message?");
+  // Edit mode stays active while the dialog is open — it exits only on confirm.
+  await expect(page.getByTestId("edit-target")).toBeVisible();
 
   // Confirm → the message row is removed and edit mode has exited.
   await dialog.getByRole("button", { name: "Delete" }).click();
@@ -78,9 +80,11 @@ test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
   const dialog = page.getByRole("alertdialog");
   await expect(dialog).toBeVisible({ timeout: 5_000 });
 
-  // Cancel → nothing is deleted; the original message survives.
+  // Cancel → nothing is deleted, the original message survives, and the user is
+  // left in edit mode (the editing session is preserved, not discarded).
   await dialog.getByRole("button", { name: "Cancel" }).click();
   await expect(dialog).toBeHidden({ timeout: 5_000 });
+  await expect(page.getByTestId("edit-target")).toBeVisible();
   await expect(row).toBeVisible();
   await expect(page.getByTestId("message-timeline")).toContainText(
     ORIGINAL_CONTENT,
