diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js
--- a/lib/selectors/chat-selectors.js
+++ b/lib/selectors/chat-selectors.js
@@ -275,7 +275,7 @@
 export type RobotextChatMessageInfoItem = {
   +itemType: 'message',
   +messageInfoType: 'robotext',
-  +messageInfo: RobotextMessageInfo,
+  +messageInfos: $ReadOnlyArray<RobotextMessageInfo>,
   +startsConversation: boolean,
   +startsCluster: boolean,
   endsCluster: boolean,
@@ -549,7 +549,7 @@
       chatMessageItems.push({
         itemType: 'message',
         messageInfoType: 'robotext',
-        messageInfo: originalMessageInfo,
+        messageInfos: [originalMessageInfo],
         startsConversation,
         startsCluster,
         endsCluster: false,
diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js
--- a/lib/shared/chat-message-item-utils.js
+++ b/lib/shared/chat-message-item-utils.js
@@ -14,12 +14,19 @@
 
 // This complicated type matches both ChatMessageItem and
 // ChatMessageItemWithHeight, and is a disjoint union of types
-type BaseChatMessageInfoItem = {
-  +itemType: 'message',
-  +messageInfo: ChatMessageItemMessageInfo,
-  +messageInfos?: ?void,
-  ...
-};
+type BaseChatMessageInfoItem =
+  | {
+      +itemType: 'message',
+      +messageInfo: ChatMessageItemMessageInfo,
+      +messageInfos?: ?void,
+      ...
+    }
+  | {
+      +itemType: 'message',
+      +messageInfos: $ReadOnlyArray<ChatMessageItemMessageInfo>,
+      +messageInfo?: ?void,
+      ...
+    };
 type BaseChatMessageItem =
   | BaseChatMessageInfoItem
   | {
@@ -33,26 +40,49 @@
   if (item.itemType === 'loader') {
     return 'loader';
   }
-  return messageKey(item.messageInfo);
+  if (item.messageInfo) {
+    return messageKey(item.messageInfo);
+  }
+  return item.messageInfos.map(messageKey).join('^');
 }
 
 function chatMessageInfoItemTimestamp(item: BaseChatMessageInfoItem): string {
-  return longAbsoluteDate(item.messageInfo.time);
+  // If there's an array of messageInfos, we expect at least one,
+  // and the most recent should be first
+  const messageInfo = item.messageInfo
+    ? item.messageInfo
+    : item.messageInfos[0];
+  return longAbsoluteDate(messageInfo.time);
 }
 
+// If the ChatMessageInfoItem can be the target of operations like sidebar
+// creation, reaction, or pinning, then this function returns the RawMessageInfo
+// that would be the target. Currently, the only reason that a
+// ChatMessageInfoItem can't be such a target would be if it's a combined
+// RobotextChatMessageInfoItem that has multiple messageInfos.
 function chatMessageItemEngagementTargetMessageInfo(
   item: BaseChatMessageInfoItem,
-): ComposableMessageInfo | RobotextMessageInfo {
-  return item.messageInfo;
+): ?ComposableMessageInfo | RobotextMessageInfo {
+  if (item.messageInfo) {
+    return item.messageInfo;
+  } else if (item.messageInfos && item.messageInfos.length === 1) {
+    return item.messageInfos[0];
+  }
+  return null;
 }
 
 function chatMessageItemHasNonViewerMessage(
   item: BaseChatMessageItem,
   viewerID: ?string,
 ): boolean {
-  return (
-    item.itemType === 'message' && item.messageInfo.creator.id !== viewerID
-  );
+  if (item.messageInfo) {
+    return item.messageInfo.creator.id !== viewerID;
+  } else if (item.messageInfos) {
+    return item.messageInfos.some(
+      messageInfo => messageInfo.creator.id !== viewerID,
+    );
+  }
+  return false;
 }
 
 type BaseChatMessageItemForEngagementCheck = {
diff --git a/lib/shared/edit-messages-utils.js b/lib/shared/edit-messages-utils.js
--- a/lib/shared/edit-messages-utils.js
+++ b/lib/shared/edit-messages-utils.js
@@ -100,7 +100,7 @@
 
 function useCanEditMessage(
   threadInfo: ThreadInfo,
-  targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
+  targetMessageInfo: ?ComposableMessageInfo | RobotextMessageInfo,
 ): boolean {
   const currentUserInfo = useSelector(state => state.currentUserInfo);
   const currentUserCanEditMessage = useThreadHasPermission(
@@ -111,7 +111,11 @@
     return false;
   }
 
-  if (!targetMessageInfo.id || targetMessageInfo.type !== messageTypes.TEXT) {
+  if (
+    !targetMessageInfo ||
+    !targetMessageInfo.id ||
+    targetMessageInfo.type !== messageTypes.TEXT
+  ) {
     return false;
   }
 
diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js
--- a/lib/shared/reaction-utils.js
+++ b/lib/shared/reaction-utils.js
@@ -76,12 +76,11 @@
 
 function useCanCreateReactionFromMessage(
   threadInfo: ThreadInfo,
-  targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
+  targetMessageInfo: ?ComposableMessageInfo | RobotextMessageInfo,
 ): boolean {
-  const targetMessageCreatorRelationship = useSelector(
-    state =>
-      state.userStore.userInfos[targetMessageInfo.creator.id]
-        ?.relationshipStatus,
+  const creatorID = targetMessageInfo?.creator.id;
+  const targetMessageCreatorRelationship = useSelector(state =>
+    creatorID ? state.userStore.userInfos[creatorID]?.relationshipStatus : null,
   );
 
   const userHasReactionPermission = useThreadHasPermission(
@@ -93,6 +92,7 @@
   }
 
   if (
+    !targetMessageInfo ||
     (!targetMessageInfo.id && !threadTypeIsThick(threadInfo.type)) ||
     (threadInfo.sourceMessageID &&
       threadInfo.sourceMessageID === targetMessageInfo.id)
diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js
--- a/lib/shared/sidebar-utils.js
+++ b/lib/shared/sidebar-utils.js
@@ -197,10 +197,11 @@
 
 function useCanCreateSidebarFromMessage(
   threadInfo: ThreadInfo,
-  messageInfo: ComposableMessageInfo | RobotextMessageInfo,
+  messageInfo: ?ComposableMessageInfo | RobotextMessageInfo,
 ): boolean {
-  const messageCreatorUserInfo = useSelector(
-    state => state.userStore.userInfos[messageInfo.creator.id],
+  const creatorID = messageInfo?.creator.id;
+  const messageCreatorUserInfo = useSelector(state =>
+    creatorID ? state.userStore.userInfos[creatorID] : null,
   );
   const hasCreateSidebarsPermission = useThreadHasPermission(
     threadInfo,
@@ -211,6 +212,7 @@
   }
 
   if (
+    !messageInfo ||
     (!messageInfo.id && !threadTypeIsThick(threadInfo.type)) ||
     (threadInfo.sourceMessageID &&
       threadInfo.sourceMessageID === messageInfo.id) ||
diff --git a/lib/utils/message-pinning-utils.js b/lib/utils/message-pinning-utils.js
--- a/lib/utils/message-pinning-utils.js
+++ b/lib/utils/message-pinning-utils.js
@@ -27,16 +27,18 @@
 }
 
 function useCanToggleMessagePin(
-  messageInfo: MessageInfo,
+  messageInfo: ?MessageInfo,
   threadInfo: ThreadInfo,
 ): boolean {
-  const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo);
   const hasManagePinsPermission = useThreadHasPermission(
     threadInfo,
     threadPermissions.MANAGE_PINS,
   );
-
-  return isValidMessage && hasManagePinsPermission;
+  return (
+    !!messageInfo &&
+    hasManagePinsPermission &&
+    !isInvalidPinSourceForThread(messageInfo, threadInfo)
+  );
 }
 
 function pinnedMessageCountText(pinnedCount: number): string {
diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js
--- a/native/chat/chat-item-height-measurer.react.js
+++ b/native/chat/chat-item-height-measurer.react.js
@@ -38,7 +38,7 @@
   const { messageInfo, hasBeenEdited, threadCreatedFromMessage, reactions } =
     item;
 
-  if (messageInfo.type === messageTypes.TEXT) {
+  if (messageInfo && messageInfo.type === messageTypes.TEXT) {
     return JSON.stringify({
       text: messageInfo.text,
       edited: getMessageLabel(hasBeenEdited, messageInfo.threadID),
@@ -46,7 +46,7 @@
       reactions: reactionsToRawString(reactions),
     });
   } else if (item.robotext) {
-    const { threadID } = item.messageInfo;
+    const { threadID } = item.messageInfos[0];
     return JSON.stringify({
       robotext: entityTextToRawString(item.robotext, { threadID }),
       sidebar: getInlineEngagementSidebarText(threadCreatedFromMessage),
@@ -73,7 +73,7 @@
   const { messageInfo, hasBeenEdited, threadCreatedFromMessage, reactions } =
     item;
 
-  if (messageInfo.type === messageTypes.TEXT) {
+  if (messageInfo && messageInfo.type === messageTypes.TEXT) {
     const label = getMessageLabel(hasBeenEdited, messageInfo.threadID);
     return dummyNodeForTextMessageHeightMeasurement(
       messageInfo.text,
@@ -84,7 +84,7 @@
   } else if (item.robotext) {
     return dummyNodeForRobotextMessageHeightMeasurement(
       item.robotext,
-      messageInfo.threadID,
+      item.messageInfos[0].threadID,
       threadCreatedFromMessage,
       reactions,
     );
@@ -116,6 +116,26 @@
         return item;
       }
 
+      if (item.messageInfoType !== 'composable') {
+        invariant(
+          height !== null && height !== undefined,
+          'height should be set',
+        );
+        return {
+          itemType: 'message',
+          messageShapeType: 'robotext',
+          messageInfos: item.messageInfos,
+          threadInfo,
+          startsConversation: item.startsConversation,
+          startsCluster: item.startsCluster,
+          endsCluster: item.endsCluster,
+          threadCreatedFromMessage: item.threadCreatedFromMessage,
+          robotext: item.robotext,
+          contentHeight: height,
+          reactions: item.reactions,
+        };
+      }
+
       const { messageInfo } = item;
       const messageType: MessageType = messageInfo.type;
       invariant(
@@ -181,29 +201,11 @@
           isPinned: item.isPinned,
         };
       }
-      invariant(
-        item.messageInfoType !== 'composable',
+
+      throw new Error(
         'ChatItemHeightMeasurer was handed a messageInfoType=composable, but ' +
           `does not know how to handle MessageType ${messageInfo.type}`,
       );
-      invariant(
-        item.messageInfoType === 'robotext',
-        'ChatItemHeightMeasurer was handed a messageInfoType that it does ' +
-          `not recognize: ${item.messageInfoType}`,
-      );
-      return {
-        itemType: 'message',
-        messageShapeType: 'robotext',
-        messageInfo,
-        threadInfo,
-        startsConversation: item.startsConversation,
-        startsCluster: item.startsCluster,
-        endsCluster: item.endsCluster,
-        threadCreatedFromMessage: item.threadCreatedFromMessage,
-        robotext: item.robotext,
-        contentHeight: height,
-        reactions: item.reactions,
-      };
     },
     [composedMessageMaxWidth, inputStatePendingUploads, threadInfo],
   );
diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js
--- a/native/chat/inner-robotext-message.react.js
+++ b/native/chat/inner-robotext-message.react.js
@@ -54,8 +54,8 @@
   const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme);
   const styles = useOverlayStyles(unboundStyles);
 
-  const { messageInfo, robotext } = item;
-  const { threadID } = messageInfo;
+  const { messageInfos, robotext } = item;
+  const { threadID } = messageInfos[0];
   const resolvedRobotext = useResolvedEntityText(robotext);
   invariant(
     resolvedRobotext,
diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js
--- a/native/chat/robotext-message.react.js
+++ b/native/chat/robotext-message.react.js
@@ -66,7 +66,10 @@
   const engagementTargetMessageInfo =
     chatMessageItemEngagementTargetMessageInfo(item);
   let inlineEngagement = null;
-  if (chatMessageItemHasEngagement(item, item.threadInfo.id)) {
+  if (
+    engagementTargetMessageInfo &&
+    chatMessageItemHasEngagement(item, item.threadInfo.id)
+  ) {
     inlineEngagement = (
       <View style={styles.sidebar}>
         <InlineEngagement
diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js
--- a/native/chat/sidebar-navigation.js
+++ b/native/chat/sidebar-navigation.js
@@ -41,6 +41,10 @@
   }
 
   const { messageInfo, threadInfo } = sourceMessage;
+  if (!messageInfo) {
+    return null;
+  }
+
   return createUnresolvedPendingSidebar({
     sourceMessageInfo: messageInfo,
     parentThreadInfo: threadInfo,
@@ -75,6 +79,10 @@
   }
 
   const { messageInfo, threadInfo } = sourceMessage;
+  if (!messageInfo) {
+    return null;
+  }
+
   return await createPendingSidebar({
     sourceMessageInfo: messageInfo,
     parentThreadInfo: threadInfo,
@@ -125,8 +133,11 @@
   const chatContext = React.useContext(ChatContext);
   const setSidebarSourceID = chatContext?.setCurrentTransitionSidebarSourceID;
   const navigateToSidebar = useNavigateToSidebar(item);
-  const messageID = item.messageInfo.id;
+  const messageID = item.messageInfo?.id;
   return React.useCallback(() => {
+    if (!messageID) {
+      return;
+    }
     setSidebarSourceID && setSidebarSourceID(messageID);
     navigateToSidebar();
   }, [setSidebarSourceID, messageID, navigateToSidebar]);
diff --git a/native/chat/utils.js b/native/chat/utils.js
--- a/native/chat/utils.js
+++ b/native/chat/utils.js
@@ -153,9 +153,10 @@
     };
   }
 
-  const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer
-    ? 0
-    : authorNameHeight;
+  const authorNameComponentHeight =
+    !sourceMessage.messageInfo || sourceMessage.messageInfo.creator.isViewer
+      ? 0
+      : authorNameHeight;
   const currentDistanceFromBottom =
     messageListVerticalBounds.height +
     messageListVerticalBounds.y -
diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js
--- a/native/search/message-search.react.js
+++ b/native/search/message-search.react.js
@@ -228,8 +228,21 @@
 
 function oldestMessage(data: $ReadOnlyArray<ChatMessageItemWithHeight>) {
   for (let i = data.length - 1; i >= 0; i--) {
-    if (data[i].itemType === 'message' && data[i].messageInfo.id) {
-      return data[i].messageInfo;
+    const item = data[i];
+    if (item.itemType !== 'message') {
+      continue;
+    }
+    if (item.messageShapeType !== 'robotext') {
+      if (item.messageInfo.id) {
+        return item.messageInfo;
+      }
+      continue;
+    }
+    for (let j = item.messageInfos.length - 1; j >= 0; j--) {
+      const messageInfo = item.messageInfos[j];
+      if (messageInfo.id) {
+        return messageInfo;
+      }
     }
   }
   return undefined;
diff --git a/native/types/chat-types.js b/native/types/chat-types.js
--- a/native/types/chat-types.js
+++ b/native/types/chat-types.js
@@ -15,7 +15,7 @@
 export type ChatRobotextMessageInfoItemWithHeight = {
   +itemType: 'message',
   +messageShapeType: 'robotext',
-  +messageInfo: RobotextMessageInfo,
+  +messageInfos: $ReadOnlyArray<RobotextMessageInfo>,
   +threadInfo: ThreadInfo,
   +startsConversation: boolean,
   +startsCluster: boolean,
diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js
--- a/web/chat/chat-message-list.react.js
+++ b/web/chat/chat-message-list.react.js
@@ -135,6 +135,7 @@
       (hasNewMessage &&
         messageListData &&
         messageListData[0].itemType === 'message' &&
+        messageListData[0].messageInfoType === 'composable' &&
         messageListData[0].messageInfo.localID) ||
       (hasNewMessage &&
         snapshot &&
diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js
--- a/web/chat/robotext-message.react.js
+++ b/web/chat/robotext-message.react.js
@@ -41,9 +41,12 @@
   let inlineEngagement;
   const { item, threadInfo } = props;
   const { threadCreatedFromMessage, reactions } = item;
-  if (threadCreatedFromMessage || Object.keys(reactions).length > 0) {
-    const engagementTargetMessageInfo =
-      chatMessageItemEngagementTargetMessageInfo(item);
+  const engagementTargetMessageInfo =
+    chatMessageItemEngagementTargetMessageInfo(item);
+  if (
+    engagementTargetMessageInfo &&
+    (threadCreatedFromMessage || Object.keys(reactions).length > 0)
+  ) {
     inlineEngagement = (
       <div className={css.sidebarMarginTop}>
         <InlineEngagement
@@ -57,8 +60,8 @@
     );
   }
 
-  const { messageInfo, robotext } = item;
-  const { threadID } = messageInfo;
+  const { messageInfos, robotext } = item;
+  const { threadID } = messageInfos[0];
   const resolvedRobotext = useResolvedEntityText(robotext);
   invariant(
     resolvedRobotext,
diff --git a/web/components/message-result.react.js b/web/components/message-result.react.js
--- a/web/components/message-result.react.js
+++ b/web/components/message-result.react.js
@@ -37,7 +37,9 @@
 
   const shouldShowUsername = !item.startsConversation && !item.startsCluster;
   const username = useStringForUser(
-    shouldShowUsername ? item.messageInfo.creator : null,
+    shouldShowUsername && item.messageInfoType === 'composable'
+      ? item.messageInfo.creator
+      : null,
   );
 
   const messageContainerClassNames = classNames({
diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js
--- a/web/selectors/thread-selectors.js
+++ b/web/selectors/thread-selectors.js
@@ -64,7 +64,7 @@
 }
 
 function useOnClickPendingSidebar(
-  messageInfo: ComposableMessageInfo | RobotextMessageInfo,
+  messageInfo: ?ComposableMessageInfo | RobotextMessageInfo,
   threadInfo: ThreadInfo,
 ): (event: SyntheticEvent<HTMLElement>) => mixed {
   const dispatch = useDispatch();
@@ -79,7 +79,7 @@
   return React.useCallback(
     async (event: SyntheticEvent<HTMLElement>) => {
       event.preventDefault();
-      if (!loggedInUserInfo) {
+      if (!loggedInUserInfo || !messageInfo) {
         return;
       }
       const pendingSidebarInfo = await createPendingSidebar({
diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js
--- a/web/tooltips/tooltip-action-utils.js
+++ b/web/tooltips/tooltip-action-utils.js
@@ -175,7 +175,6 @@
   item: ChatMessageInfoItem,
   threadInfo: ThreadInfo,
 ): ?MessageTooltipAction {
-  const { messageInfo } = item;
   const { popModal } = useModalContext();
   const inputState = React.useContext(InputStateContext);
   invariant(inputState, 'inputState is required');
@@ -184,8 +183,18 @@
     threadInfo,
     threadPermissions.VOICED,
   );
+
+  let messageInfo;
+  if (
+    item.messageInfoType === 'composable' &&
+    item.messageInfo.type === messageTypes.TEXT &&
+    currentUserIsVoiced
+  ) {
+    messageInfo = item.messageInfo;
+  }
+
   return React.useMemo(() => {
-    if (item.messageInfo.type !== messageTypes.TEXT || !currentUserIsVoiced) {
+    if (!messageInfo) {
       return null;
     }
     const buttonContent = <CommIcon icon="reply-filled" size={18} />;
@@ -202,34 +211,35 @@
       onClick,
       label: 'Reply',
     };
-  }, [
-    popModal,
-    addReply,
-    item.messageInfo.type,
-    messageInfo,
-    currentUserIsVoiced,
-  ]);
+  }, [popModal, addReply, messageInfo]);
 }
 
 const copiedMessageDurationMs = 2000;
 function useMessageCopyAction(
   item: ChatMessageInfoItem,
 ): ?MessageTooltipAction {
-  const { messageInfo } = item;
-
   const [successful, setSuccessful] = useResettingState(
     false,
     copiedMessageDurationMs,
   );
 
+  let messageInfo;
+  if (
+    item.messageInfoType === 'composable' &&
+    item.messageInfo.type === messageTypes.TEXT
+  ) {
+    messageInfo = item.messageInfo;
+  }
+
+  const messageText = messageInfo?.text;
   return React.useMemo(() => {
-    if (messageInfo.type !== messageTypes.TEXT) {
+    if (!messageText) {
       return null;
     }
     const buttonContent = <CommIcon icon="copy-filled" size={18} />;
     const onClick = async () => {
       try {
-        await navigator.clipboard.writeText(messageInfo.text);
+        await navigator.clipboard.writeText(messageText);
         setSuccessful(true);
       } catch (e) {
         setSuccessful(false);
@@ -240,7 +250,7 @@
       onClick,
       label: successful ? 'Copied!' : 'Copy',
     };
-  }, [messageInfo.text, messageInfo.type, setSuccessful, successful]);
+  }, [messageText, setSuccessful, successful]);
 }
 
 function useMessageReactAction(
@@ -337,6 +347,10 @@
       item.messageInfoType === 'composable',
       'canEditMessage should only be true for composable messages!',
     );
+    invariant(
+      messageInfo && messageInfo.type === messageTypes.TEXT,
+      'canEditMessage should only be true for text messages!',
+    );
     const buttonContent = <CommIcon icon="edit-filled" size={18} />;
     const onClickEdit = () => {
       const callback = (maxHeight: number) =>