diff --git a/AGENTS.md b/AGENTS.md
index 7d9955d151..a8dcb7d8b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -412,6 +412,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803).
 4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
 5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
 6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
+7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
 
 ---
 
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 7d422b9ce0..a00083cc9d 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -528,6 +528,7 @@ export function ChannelScreen({
     (message: TimelineMessage) => handleMarkMessageRead(message.id),
     [handleMarkMessageRead],
   );
+  const sendMessageMutateAsync = sendMessageMutation.mutateAsync;
   const handleSendVideoReviewComment = React.useCallback(
     async (
       message: { id: string },
@@ -536,19 +537,32 @@ export function ChannelScreen({
       mediaTags?: string[][],
       parentEventId?: string,
     ) => {
-      await sendMessageMutation.mutateAsync({
+      await sendMessageMutateAsync({
         content,
         mediaTags,
         mentionPubkeys,
         parentEventId: parentEventId ?? message.id,
       });
     },
-    [sendMessageMutation],
+    [sendMessageMutateAsync],
   );
   const effectiveSendVideoReviewComment =
     activeChannel && !activeChannel.archivedAt && activeChannel.isMember
       ? handleSendVideoReviewComment
       : undefined;
+  const handleOpenAddBot = React.useCallback(() => setIsAddBotOpen(true), []);
+  const handleOpenMembersSidebar = React.useCallback(
+    () => setIsMembersSidebarOpen(true),
+    [],
+  );
+  const handleCloseChannelManagement = React.useCallback(
+    () => setChannelManagementOpen(false),
+    [setChannelManagementOpen],
+  );
+  const handleChannelManagementDeleted = React.useCallback(() => {
+    setChannelManagementOpen(false);
+    void goHome({ replace: true });
+  }, [setChannelManagementOpen, goHome]);
   const {
     agentSessionAgents,
     channelAgentSessionAgents,
@@ -727,44 +741,77 @@ export function ChannelScreen({
     enabled: !isSinglePanelView,
   });
 
-  const channelHeader = (
-    <ChannelScreenHeader
-      activeChannel={activeChannel}
-      activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
-      activeChannelTitle={activeChannelTitle}
-      actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
-      activeDmAvatarUrl={activeDmAvatarUrl}
-      activeDmHeaderParticipants={activeDmHeaderParticipants}
-      activeDmPresenceStatus={activeDmPresenceStatus}
-      chromeWrapperRef={channelHeaderChromeRef}
-      currentPubkey={currentPubkey}
-      isAddBotOpen={isAddBotOpen}
-      isJoining={joinChannelMutation.isPending}
-      onAddBotOpenChange={setIsAddBotOpen}
-      onJoinChannel={joinChannelMutation.mutateAsync}
-      onManageChannel={() => {
-        if (activeChannel?.channelType === "forum") {
-          openGlobalChannelManagement();
-          return;
-        }
+  const handleManageChannel = React.useCallback(() => {
+    if (activeChannel?.channelType === "forum") {
+      openGlobalChannelManagement();
+      return;
+    }
 
-        if (channelManagementOpen) {
-          setChannelManagementOpen(false);
-          return;
-        }
+    if (channelManagementOpen) {
+      setChannelManagementOpen(false);
+      return;
+    }
+
+    setOpenThreadHeadId(null);
+    setExpandedThreadReplyIds(new Set());
+    setThreadScrollTargetId(null);
+    setThreadReplyTargetId(null);
+    handleCloseAgentSession();
+    setProfilePanelPubkey(null);
+    setChannelManagementOpen(true);
+  }, [
+    activeChannel?.channelType,
+    channelManagementOpen,
+    openGlobalChannelManagement,
+    setChannelManagementOpen,
+    setOpenThreadHeadId,
+    handleCloseAgentSession,
+    setProfilePanelPubkey,
+  ]);
+  const handleToggleMembers = React.useCallback(
+    () => setIsMembersSidebarOpen((prev) => !prev),
+    [],
+  );
 
-        setOpenThreadHeadId(null);
-        setExpandedThreadReplyIds(new Set());
-        setThreadScrollTargetId(null);
-        setThreadReplyTargetId(null);
-        handleCloseAgentSession();
-        setProfilePanelPubkey(null);
-        setChannelManagementOpen(true);
-      }}
-      onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
-      showHeaderContent={!isSinglePanelView}
-      transparentChrome={activeChannel?.channelType !== "forum"}
-    />
+  const channelHeader = React.useMemo(
+    () => (
+      <ChannelScreenHeader
+        activeChannel={activeChannel}
+        activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
+        activeChannelTitle={activeChannelTitle}
+        actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
+        activeDmAvatarUrl={activeDmAvatarUrl}
+        activeDmHeaderParticipants={activeDmHeaderParticipants}
+        activeDmPresenceStatus={activeDmPresenceStatus}
+        chromeWrapperRef={channelHeaderChromeRef}
+        currentPubkey={currentPubkey}
+        isAddBotOpen={isAddBotOpen}
+        isJoining={joinChannelMutation.isPending}
+        onAddBotOpenChange={setIsAddBotOpen}
+        onJoinChannel={joinChannelMutation.mutateAsync}
+        onManageChannel={handleManageChannel}
+        onToggleMembers={handleToggleMembers}
+        showHeaderContent={!isSinglePanelView}
+        transparentChrome={activeChannel?.channelType !== "forum"}
+      />
+    ),
+    [
+      activeChannel,
+      activeChannelEphemeralDisplay,
+      activeChannelTitle,
+      shouldCompactHeaderActions,
+      activeDmAvatarUrl,
+      activeDmHeaderParticipants,
+      activeDmPresenceStatus,
+      channelHeaderChromeRef,
+      currentPubkey,
+      isAddBotOpen,
+      joinChannelMutation.isPending,
+      joinChannelMutation.mutateAsync,
+      handleManageChannel,
+      handleToggleMembers,
+      isSinglePanelView,
+    ],
   );
 
   return (
@@ -807,9 +854,9 @@ export function ChannelScreen({
                   fetchOlder={fetchOlder}
                   header={channelHeader}
                   hasOlderMessages={hasOlderMessages}
-                  onAddAgent={() => setIsAddBotOpen(true)}
+                  onAddAgent={handleOpenAddBot}
                   onCreateChannel={openCreateChannel}
-                  onOpenMembers={() => setIsMembersSidebarOpen(true)}
+                  onOpenMembers={handleOpenMembersSidebar}
                   isFetchingOlder={isFetchingOlder}
                   editTarget={
                     editTargetMessage
@@ -834,10 +881,7 @@ export function ChannelScreen({
                   messages={timelineMessages}
                   onCancelEdit={handleCancelEdit}
                   onCancelThreadReply={handleCancelThreadReply}
-                  onChannelManagementDeleted={() => {
-                    setChannelManagementOpen(false);
-                    void goHome({ replace: true });
-                  }}
+                  onChannelManagementDeleted={handleChannelManagementDeleted}
                   onFollowThread={
                     effectiveOpenThreadHeadId != null &&
                     !isNotifiedForEffectiveThread
@@ -851,9 +895,7 @@ export function ChannelScreen({
                       : undefined
                   }
                   onCloseAgentSession={handleCloseAgentSession}
-                  onCloseChannelManagement={() =>
-                    setChannelManagementOpen(false)
-                  }
+                  onCloseChannelManagement={handleCloseChannelManagement}
                   onCloseThread={handleCloseThread}
                   onDelete={
                     activeChannel?.archivedAt ? undefined : handleDelete
diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts
index f25065c20a..9d6961a93d 100644
--- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts
+++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts
@@ -50,12 +50,13 @@ export function useChannelProfilePanel({
     setProfilePanelPubkey(null);
   }, [setProfilePanelPubkey]);
 
+  const openDmMutateAsync = openDmMutation.mutateAsync;
   const handleOpenDm = React.useCallback(
     async (pubkeys: string[]) => {
-      const dm = await openDmMutation.mutateAsync({ pubkeys });
+      const dm = await openDmMutateAsync({ pubkeys });
       await goChannel(dm.id);
     },
-    [goChannel, openDmMutation],
+    [goChannel, openDmMutateAsync],
   );
 
   return {
diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts
index b76988a835..34f16dc8f8 100644
--- a/desktop/src/features/channels/ui/useChannelUnreadState.ts
+++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts
@@ -8,6 +8,10 @@ import {
 } from "@/features/channels/lib/subtreeCreatedAt";
 import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
 import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
+import {
+  useStableArrayShallow,
+  useStableMap,
+} from "@/shared/hooks/useStableReference";
 import {
   buildThreadPanelDataFromIndex,
   buildThreadPanelIndex,
@@ -174,7 +178,7 @@ export function useChannelUnreadState({
     ],
   );
   const openThreadHeadMessage = threadPanelData.threadHead;
-  const threadMessages = threadPanelData.visibleReplies;
+  const threadMessages = useStableArrayShallow(threadPanelData.visibleReplies);
   const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
 
   // Oldest unread top-level message + count from the open-time frontier.
@@ -332,7 +336,7 @@ export function useChannelUnreadState({
   // (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
   // trigger so the badge re-reads after any marker advances.
   // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
-  const threadUnreadCounts = React.useMemo(
+  const threadUnreadCountsRaw = React.useMemo(
     () =>
       computeThreadBadgeCounts(
         timelineMessages,
@@ -353,6 +357,11 @@ export function useChannelUnreadState({
       forcedUnreadVersion,
     ],
   );
+  // Keep the same Map reference across recomputes when the entries are
+  // unchanged. readStateVersion bumps on read-state activity (frequent in busy
+  // channels) and recomputes a fresh Map even when no count changed; without
+  // this, every bump hands the timeline a new prop ref and reconciles all rows.
+  const threadUnreadCounts = useStableMap(threadUnreadCountsRaw);
 
   // Per-message unread predicate for the mark-read/unread menu toggle. Reuses
   // computeThreadUnreadMarker — the exact function the badge counts call
diff --git a/desktop/src/features/channels/useEphemeralChannelDisplay.ts b/desktop/src/features/channels/useEphemeralChannelDisplay.ts
index e9dc25a784..59daeff3d0 100644
--- a/desktop/src/features/channels/useEphemeralChannelDisplay.ts
+++ b/desktop/src/features/channels/useEphemeralChannelDisplay.ts
@@ -24,5 +24,20 @@ export function useEphemeralChannelDisplay(channel: Channel | null) {
     };
   }, [deadlineKey]);
 
-  return channel ? getEphemeralChannelDisplay(channel, Date.now()) : null;
+  const display = channel
+    ? getEphemeralChannelDisplay(channel, Date.now())
+    : null;
+  // Stabilise the reference across renders when the rendered content is
+  // unchanged: this object is recomputed from Date.now() every render, and it
+  // feeds memoised header/timeline subtrees — a fresh ref each render would
+  // defeat their React.memo on every background re-render.
+  const stableRef = React.useRef(display);
+  const prev = stableRef.current;
+  if (
+    prev?.detailLabel !== display?.detailLabel ||
+    prev?.tooltipLabel !== display?.tooltipLabel
+  ) {
+    stableRef.current = display;
+  }
+  return stableRef.current;
 }
diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx
index 867c7104cf..ec8884f98f 100644
--- a/desktop/src/features/forum/ui/ForumComposer.tsx
+++ b/desktop/src/features/forum/ui/ForumComposer.tsx
@@ -99,11 +99,11 @@ export function ForumComposer({
     isAutocompleteOpen: isAutocompleteOpenRef,
     onEditLink: (info) => onEditLinkRef.current?.(info),
     onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
-    onUpdate: ({ markdown, text }) => {
+    onUpdate: ({ cursor, text }) => {
+      const markdown = richText.getMarkdown();
       setContent(markdown);
       contentRef.current = markdown;
 
-      const { cursor } = richText.getPlainTextAndCursor();
       mentions.updateMentionQuery(text, cursor);
       channelLinks.updateChannelQuery(text, cursor);
     },
diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts
index 2c67e9c988..994e0fa0ce 100644
--- a/desktop/src/features/messages/lib/useMediaUpload.ts
+++ b/desktop/src/features/messages/lib/useMediaUpload.ts
@@ -530,26 +530,46 @@ export function useMediaUpload() {
 
   const isUploading = uploadingCount > 0;
 
-  return {
-    cancelUpload,
-    handleDragEnter,
-    handleDragLeave,
-    handleDragOver,
-    handleDrop,
-    handlePaperclip,
-    handlePaste,
-    isDragOver,
-    isUploading,
-    pendingImeta,
-    pendingImetaRef,
-    removeAttachment,
-    setPendingImeta,
-    setUploadState,
-    uploadFile,
-    uploadingCount,
-    uploadingPreviews,
-    uploadState,
-  };
+  return React.useMemo(
+    () => ({
+      cancelUpload,
+      handleDragEnter,
+      handleDragLeave,
+      handleDragOver,
+      handleDrop,
+      handlePaperclip,
+      handlePaste,
+      isDragOver,
+      isUploading,
+      pendingImeta,
+      pendingImetaRef,
+      removeAttachment,
+      setPendingImeta,
+      setUploadState,
+      uploadFile,
+      uploadingCount,
+      uploadingPreviews,
+      uploadState,
+    }),
+    [
+      cancelUpload,
+      handleDragEnter,
+      handleDragLeave,
+      handleDragOver,
+      handleDrop,
+      handlePaperclip,
+      handlePaste,
+      isDragOver,
+      isUploading,
+      pendingImeta,
+      removeAttachment,
+      setPendingImeta,
+      uploadFile,
+      uploadingCount,
+      uploadingPreviews,
+      uploadState,
+    ],
+  );
 }
 
 export type MediaUploadController = ReturnType<typeof useMediaUpload>;
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index ccabc04645..1b57b223c3 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -53,7 +53,7 @@ export type AutocompleteEdit = {
 
 export type RichTextEditorOptions = {
   placeholder?: string;
-  onUpdate?: (info: { markdown: string; text: string }) => void;
+  onUpdate?: (info: { text: string; cursor: number }) => void;
   editable?: boolean;
   mentionNames?: string[];
   agentMentionNames?: string[];
@@ -459,13 +459,15 @@ export function useRichTextEditor({
         },
       },
       onUpdate: ({ editor: ed }) => {
-        const markdown = getMarkdownFromEditor(ed);
-        // Use the same plain-text projection that `getPlainTextAndCursor`
-        // uses, so autocomplete detection sees the *same* string the
-        // cursor offset is mapped against. `state.doc.textContent` would
-        // diverge by 1 per hard-break / block boundary.
-        const text = buildPlainTextProjection(ed.state.doc).text;
-        onUpdateRef.current?.({ markdown, text });
+        // Keep the hot typing path lightweight. Markdown serialization is
+        // still available through `getMarkdown()` for send/draft boundaries;
+        // per-keystroke consumers only need textarea-shaped plain text for
+        // autocomplete and empty/non-empty state.
+        const projection = buildPlainTextProjection(ed.state.doc);
+        onUpdateRef.current?.({
+          cursor: projection.mapPMToTextOffset(ed.state.selection.anchor),
+          text: projection.text,
+        });
       },
     },
     [],
diff --git a/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx b/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx
new file mode 100644
index 0000000000..5032fa453e
--- /dev/null
+++ b/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx
@@ -0,0 +1,88 @@
+import { CornerUpLeft, Pencil, X } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+
+const BANNER_CLASS =
+  "relative z-0 -mb-4 flex transform-gpu gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors";
+
+/**
+ * The "Editing message" / "Replying to …" banner that sits above the composer
+ * input. Edit takes precedence over reply (matching the composer's own
+ * `editTarget ? … : replyTarget ? …` ordering). Rendered as a sibling so
+ * MessageComposer stays under the file-size guard; purely presentational.
+ */
+export function ComposerReplyEditBanner({
+  isEditing,
+  replyTarget,
+  onCancelEdit,
+  onCancelReply,
+}: {
+  isEditing: boolean;
+  replyTarget?: { author: string; body: string; id: string } | null;
+  onCancelEdit?: () => void;
+  onCancelReply?: () => void;
+}) {
+  if (isEditing) {
+    return (
+      <div
+        className={cn(BANNER_CLASS, "items-center")}
+        data-testid="edit-target"
+      >
+        <Pencil aria-hidden className="h-4 w-4 shrink-0" />
+        <div className="min-w-0 flex-1">
+          <p className="truncate font-medium text-foreground">
+            Editing message
+          </p>
+        </div>
+        {onCancelEdit ? (
+          <Button
+            aria-label="Cancel edit"
+            className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
+            onClick={onCancelEdit}
+            size="icon"
+            type="button"
+            variant="ghost"
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        ) : null}
+      </div>
+    );
+  }
+
+  if (replyTarget) {
+    return (
+      <div
+        className={cn(BANNER_CLASS, "items-start")}
+        data-testid="reply-target"
+      >
+        <CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
+        <div className="min-w-0 flex-1">
+          <p className="truncate font-medium text-foreground">
+            Replying to {replyTarget.author}
+          </p>
+          {replyTarget.body ? (
+            <p className="truncate text-muted-foreground/80">
+              {replyTarget.body}
+            </p>
+          ) : null}
+        </div>
+        {onCancelReply ? (
+          <Button
+            aria-label="Cancel reply"
+            className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
+            onClick={onCancelReply}
+            size="icon"
+            type="button"
+            variant="ghost"
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        ) : null}
+      </div>
+    );
+  }
+
+  return null;
+}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 37dbbe774b..746f7e1d44 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -1,7 +1,6 @@
 import * as React from "react";
 
 import { EditorContent } from "@tiptap/react";
-import { CornerUpLeft, Pencil, X } from "lucide-react";
 import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
 import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
 import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
@@ -40,8 +39,8 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
 import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
 import { cn } from "@/shared/lib/cn";
 import type { ChannelType } from "@/shared/api/types";
-import { Button } from "@/shared/ui/button";
 import { ChannelAutocomplete } from "./ChannelAutocomplete";
+import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
 import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
 import { EmojiAutocomplete } from "./EmojiAutocomplete";
 import {
@@ -51,6 +50,7 @@ import {
 import { MessageComposerToolbar } from "./MessageComposerToolbar";
 import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
 import { useMentionSendFlow } from "./useMentionSendFlow";
+import { useComposerContentState } from "./useComposerContentState";
 
 type MessageComposerProps = {
   channelId?: string | null;
@@ -104,7 +104,7 @@ type MessageComposerProps = {
   typingRootEventId?: string | null;
 };
 
-export function MessageComposer({
+function MessageComposerImpl({
   channelId = null,
   channelName,
   channelType = null,
@@ -127,10 +127,14 @@ export function MessageComposer({
   typingParentEventId = null,
   typingRootEventId = null,
 }: MessageComposerProps) {
-  const [content, setContent] = React.useState("");
-  const contentRef = React.useRef(content);
-  contentRef.current = content;
-
+  const {
+    contentRef,
+    isContentEmpty,
+    setComposerContent,
+    setComposerContentFromText,
+    syncComposerContentFromEditor,
+    syncContentRefFromEditorRef,
+  } = useComposerContentState();
   const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
   const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
   const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -238,14 +242,9 @@ export function MessageComposer({
     isAutocompleteOpen: isAutocompleteOpenRef,
     onEditLink: (info) => onEditLinkRef.current?.(info),
     onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
-    onUpdate: ({ markdown, text }) => {
-      setContent(markdown);
-      contentRef.current = markdown;
+    onUpdate: ({ cursor, text }) => {
+      setComposerContentFromText(text);
 
-      // Bridge to mention/channel/emoji detection hooks. `text` and the
-      // cursor are both in plain-text-offset space (treating hardBreak
-      // and inter-block boundaries as `\n`).
-      const { cursor } = richText.getPlainTextAndCursor();
       mentions.updateMentionQuery(text, cursor);
       channelLinks.updateChannelQuery(text, cursor);
       emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -257,6 +256,11 @@ export function MessageComposer({
   });
 
   const linkEditor = useLinkEditor(richText);
+  syncContentRefFromEditorRef.current = () => {
+    const markdown = richText.getMarkdown();
+    contentRef.current = markdown;
+    return markdown;
+  };
   onEditLinkRef.current = linkEditor.openFromClick;
   onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
   useComposerSpoilerParticles(richText.editor, composerScrollRef);
@@ -272,7 +276,7 @@ export function MessageComposer({
     mentions,
     onSendRef,
     richText,
-    setContent,
+    setContent: setComposerContent,
     setIsEmojiPickerOpen,
     setPendingImeta: media.setPendingImeta,
     setSpoileredAttachmentUrls,
@@ -282,7 +286,7 @@ export function MessageComposer({
   React.useEffect(() => {
     const prevKey = previousDraftKeyRef.current;
     if (prevKey) {
-      drafts.persistDraft(prevKey, contentRef.current);
+      drafts.persistDraft(prevKey, syncComposerContentFromEditor());
     }
     previousDraftKeyRef.current = effectiveDraftKey;
 
@@ -290,12 +294,10 @@ export function MessageComposer({
       ? drafts.loadDraft(effectiveDraftKey)
       : undefined;
     if (saved) {
-      setContent(saved.content);
-      contentRef.current = saved.content;
+      setComposerContent(saved.content);
       richText.setContent(saved.content);
     } else {
-      setContent("");
-      contentRef.current = "";
+      setComposerContent("");
       richText.clearContent();
     }
 
@@ -309,7 +311,7 @@ export function MessageComposer({
 
     return () => {
       if (effectiveDraftKey) {
-        drafts.persistDraft(effectiveDraftKey, contentRef.current);
+        drafts.persistDraft(effectiveDraftKey, syncComposerContentFromEditor());
       }
     };
   }, [effectiveDraftKey]);
@@ -321,7 +323,7 @@ export function MessageComposer({
       // in-flight work survives the edit-mode hijack and is restored on
       // edit-cancel/exit.
       preEditSnapshotRef.current = {
-        content: contentRef.current,
+        content: syncComposerContentFromEditor(),
         pendingImeta: [...media.pendingImetaRef.current],
         spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
       };
@@ -332,8 +334,7 @@ export function MessageComposer({
         editTarget.body,
         editTarget.imetaMedia ?? [],
       );
-      setContent(editableBody);
-      contentRef.current = editableBody;
+      setComposerContent(editableBody);
       richText.setContent(editableBody);
       // Seed the composer's pending-imeta state with the original event's
       // attachments so they show up in `ComposerAttachments` and the user
@@ -360,8 +361,7 @@ export function MessageComposer({
         spoileredAttachmentUrls: restoredSpoileredAttachmentUrls,
       } = preEditSnapshotRef.current;
       preEditSnapshotRef.current = null;
-      setContent(restoredContent);
-      contentRef.current = restoredContent;
+      setComposerContent(restoredContent);
       restoredContent
         ? richText.setContent(restoredContent)
         : richText.clearContent();
@@ -502,7 +502,7 @@ export function MessageComposer({
 
   // ── Submit message ──────────────────────────────────────────────────
   const submitMessage = React.useCallback(async () => {
-    const trimmed = contentRef.current.trim();
+    const trimmed = syncComposerContentFromEditor().trim();
 
     // Edit mode
     if (editTargetRef.current && onEditSaveRef.current) {
@@ -537,8 +537,7 @@ export function MessageComposer({
       const savedContent = trimmed;
       const savedImeta = [...currentPendingImeta];
       const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls);
-      setContent("");
-      contentRef.current = "";
+      setComposerContent("");
       richText.clearContent();
       media.setPendingImeta([]);
       setSpoileredAttachmentUrls(new Set());
@@ -550,8 +549,7 @@ export function MessageComposer({
       try {
         await onEditSaveRef.current(finalContent, outgoingTags);
       } catch {
-        setContent(savedContent);
-        contentRef.current = savedContent;
+        setComposerContent(savedContent);
         richText.setContent(savedContent);
         media.setPendingImeta(savedImeta);
         setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls);
@@ -589,7 +587,9 @@ export function MessageComposer({
     mentions.clearMentions,
     richText.clearContent,
     richText.setContent,
+    setComposerContent,
     spoileredAttachmentUrls,
+    syncComposerContentFromEditor,
   ]);
   submitMessageRef.current = submitMessage;
 
@@ -744,12 +744,12 @@ export function MessageComposer({
       disabled ||
       media.isUploading ||
       mentionSendFlow.isPreparingMentionSend ||
-      (content.trim().length === 0 && media.pendingImeta.length === 0),
+      (isContentEmpty && media.pendingImeta.length === 0),
     [
       disabled,
       media.isUploading,
       mentionSendFlow.isPreparingMentionSend,
-      content,
+      isContentEmpty,
       media.pendingImeta.length,
     ],
   );
@@ -825,60 +825,12 @@ export function MessageComposer({
           className="absolute inset-x-0 bottom-0 h-5 bg-background"
         />
         <div className="relative flex w-full flex-col gap-0">
-          {editTarget ? (
-            <div
-              className="relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
-              data-testid="edit-target"
-            >
-              <Pencil aria-hidden className="h-4 w-4 shrink-0" />
-              <div className="min-w-0 flex-1">
-                <p className="truncate font-medium text-foreground">
-                  Editing message
-                </p>
-              </div>
-              {onCancelEdit ? (
-                <Button
-                  aria-label="Cancel edit"
-                  className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
-                  onClick={onCancelEdit}
-                  size="icon"
-                  type="button"
-                  variant="ghost"
-                >
-                  <X className="h-4 w-4" />
-                </Button>
-              ) : null}
-            </div>
-          ) : replyTarget ? (
-            <div
-              className="relative z-0 -mb-4 flex transform-gpu items-start gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
-              data-testid="reply-target"
-            >
-              <CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
-              <div className="min-w-0 flex-1">
-                <p className="truncate font-medium text-foreground">
-                  Replying to {replyTarget.author}
-                </p>
-                {replyTarget.body ? (
-                  <p className="truncate text-muted-foreground/80">
-                    {replyTarget.body}
-                  </p>
-                ) : null}
-              </div>
-              {onCancelReply ? (
-                <Button
-                  aria-label="Cancel reply"
-                  className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
-                  onClick={onCancelReply}
-                  size="icon"
-                  type="button"
-                  variant="ghost"
-                >
-                  <X className="h-4 w-4" />
-                </Button>
-              ) : null}
-            </div>
-          ) : null}
+          <ComposerReplyEditBanner
+            isEditing={editTarget != null}
+            replyTarget={replyTarget}
+            onCancelEdit={onCancelEdit}
+            onCancelReply={onCancelReply}
+          />
           <form
             className="relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
             data-testid="message-composer"
@@ -996,3 +948,5 @@ export function MessageComposer({
     </>
   );
 }
+
+export const MessageComposer = React.memo(MessageComposerImpl);
diff --git a/desktop/src/features/messages/ui/useComposerContentState.ts b/desktop/src/features/messages/ui/useComposerContentState.ts
new file mode 100644
index 0000000000..533665012d
--- /dev/null
+++ b/desktop/src/features/messages/ui/useComposerContentState.ts
@@ -0,0 +1,39 @@
+import * as React from "react";
+
+export function useComposerContentState() {
+  const contentRef = React.useRef("");
+  const [isContentEmpty, setIsContentEmpty] = React.useState(true);
+
+  const setComposerContentFromText = React.useCallback((nextText: string) => {
+    setIsContentEmpty((wasEmpty) => {
+      const isEmpty = nextText.trim().length === 0;
+      return wasEmpty === isEmpty ? wasEmpty : isEmpty;
+    });
+  }, []);
+
+  const setComposerContent = React.useCallback(
+    (nextContent: string) => {
+      contentRef.current = nextContent;
+      setComposerContentFromText(nextContent);
+    },
+    [setComposerContentFromText],
+  );
+
+  const syncContentRefFromEditorRef = React.useRef<() => string>(
+    () => contentRef.current,
+  );
+
+  const syncComposerContentFromEditor = React.useCallback(
+    () => syncContentRefFromEditorRef.current(),
+    [],
+  );
+
+  return {
+    contentRef,
+    isContentEmpty,
+    setComposerContent,
+    setComposerContentFromText,
+    syncComposerContentFromEditor,
+    syncContentRefFromEditorRef,
+  };
+}
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index e60e988f4b..daf63e50f5 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -62,7 +62,7 @@ type UseMentionSendFlowOptions = {
     ) => Promise<void>
   >;
   richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
-  setContent: React.Dispatch<React.SetStateAction<string>>;
+  setContent: (content: string) => void;
   setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
   setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
   setSpoileredAttachmentUrls?: React.Dispatch<
diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts
index 82f381a465..fbe23bb5b1 100644
--- a/desktop/src/features/search/useChannelFind.ts
+++ b/desktop/src/features/search/useChannelFind.ts
@@ -162,16 +162,29 @@ export function useChannelFind({
     }
   }, [channelId, reset]);
 
-  return {
-    activeIndex,
-    activeMatch,
-    close,
-    goToNext,
-    goToPrevious,
-    isOpen,
-    matchCount: matchedIds.length,
-    matchingMessageIds,
-    query,
-    setQuery,
-  };
+  return React.useMemo(
+    () => ({
+      activeIndex,
+      activeMatch,
+      close,
+      goToNext,
+      goToPrevious,
+      isOpen,
+      matchCount: matchedIds.length,
+      matchingMessageIds,
+      query,
+      setQuery,
+    }),
+    [
+      activeIndex,
+      activeMatch,
+      close,
+      goToNext,
+      goToPrevious,
+      isOpen,
+      matchedIds.length,
+      matchingMessageIds,
+      query,
+    ],
+  );
 }
diff --git a/desktop/src/shared/hooks/useStableReference.ts b/desktop/src/shared/hooks/useStableReference.ts
new file mode 100644
index 0000000000..e02bca4032
--- /dev/null
+++ b/desktop/src/shared/hooks/useStableReference.ts
@@ -0,0 +1,54 @@
+import * as React from "react";
+
+/**
+ * Returns `next` but preserves the previous reference whenever the two Maps
+ * have identical entries (same size, same key→value pairs). Lets a value that
+ * is recomputed into a fresh Map on an unrelated invalidation (e.g. a version
+ * bump) keep a stable identity so downstream `React.memo` boundaries can bail.
+ */
+export function useStableMap<K, V>(next: Map<K, V>): Map<K, V> {
+  const ref = React.useRef(next);
+  const prev = ref.current;
+  if (prev !== next && mapsEqual(prev, next)) {
+    return prev;
+  }
+  ref.current = next;
+  return next;
+}
+
+function mapsEqual<K, V>(a: Map<K, V>, b: Map<K, V>): boolean {
+  if (a.size !== b.size) return false;
+  for (const [key, value] of a) {
+    if (!b.has(key) || !Object.is(b.get(key), value)) return false;
+  }
+  return true;
+}
+
+/**
+ * Returns `next` but preserves the previous reference when the two arrays are
+ * shallow-equal (same length, `Object.is` on each element). Same purpose as
+ * `useStableMap` for array-valued derived state. Preserves the caller's exact
+ * array type (mutable or readonly).
+ */
+export function useStableArrayShallow<T extends readonly unknown[]>(
+  next: T,
+): T {
+  const ref = React.useRef(next);
+  const prev = ref.current;
+  if (prev !== next && arraysShallowEqual(prev, next)) {
+    return prev;
+  }
+  ref.current = next;
+  return next;
+}
+
+function arraysShallowEqual(
+  a: readonly unknown[],
+  b: readonly unknown[],
+): boolean {
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i += 1) {
+    if (!Object.is(a[i], b[i])) return false;
+  }
+  return true;
+}
diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts
index 5fdeb4dc81..84f7bd8cdd 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -199,6 +199,11 @@ test("preserves user scroll while older channel history loads", async ({
 
   // Snapshot the oldest rendered index BEFORE firing the delayed page, so the
   // poll's growth gate can require a genuinely NEW older index after it lands.
+  // Wait for a concrete rendered row first: under CI load the initial rows can
+  // lag, leaving the scan with nothing to match (null) and a spurious failure.
+  await expect
+    .poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
+    .not.toBeNull();
   const oldestBeforeLanding = await oldestRenderedIndex();
   expect(oldestBeforeLanding).not.toBeNull();
 
@@ -243,7 +248,7 @@ test("preserves user scroll while older channel history loads", async ({
         return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
       },
       {
-        timeout: 3_000,
+        timeout: 8_000,
       },
     )
     .toBeLessThanOrEqual(2);
@@ -413,9 +418,12 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
   // messages land; we poll for that growth and then assert we are STILL
   // at the bottom (no upward teleport to the abandoned anchor).
   //
-  // Timeout: 6s. The wheel-up + smooth-abandon path can burn 2-3s of
-  // the 5s historyDelayMs window before this poll begins, so the
-  // prepend may not land for another 2-3s. 6s leaves margin.
+  // Timeout: 12s. The wheel-up + smooth-abandon path can burn 2-3s of the
+  // 5s historyDelayMs window before this poll begins, so the prepend may not
+  // land for another 2-3s. On a loaded CI runner that squeezes a tight 6s
+  // budget into a timeout (the prepend is a deterministic 5s mock timer, so
+  // the only failure mode is waiting too little); 12s leaves comfortable
+  // margin without weakening the assertion below.
   await expect
     .poll(
       async () => {
@@ -424,7 +432,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
           ? "resolved"
           : "pending";
       },
-      { timeout: 6_000 },
+      { timeout: 12_000 },
     )
     .toBe("resolved");
 
@@ -1756,6 +1764,15 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
       return Number.isFinite(min) ? min : null;
     });
 
+  // Wait until at least one prepended "mock older" row has actually rendered
+  // before sampling the oldest index. On a loaded CI runner the prepended
+  // history can lag the scrollable-height gate above, leaving the scan with no
+  // "mock older N" rows yet (oldestBeforeLanding === null). Poll for a concrete
+  // older row so the baseline is deterministic.
+  await expect
+    .poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
+    .not.toBeNull();
+
   const oldestBeforeLanding = await oldestRenderedIndex();
   expect(oldestBeforeLanding).not.toBeNull();
 
@@ -1794,7 +1811,7 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
         }
         return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
       },
-      { timeout: 4_000 },
+      { timeout: 8_000 },
     )
     .toBeLessThanOrEqual(2);
 
