Add the ability to reply to a message (#7170)

Remco Smits , Bennet Bo Fenner , and Conrad Irwin created

Feature
- [x] Allow to click on reply to go to the real message
    - [x] In chat
- [x] Show only a part of the message that you reply to
    - [x] In chat
    - [x] In reply preview

TODO’s
- [x] Fix migration
    - [x] timestamp(in filename)
    - [x] remove the reference to the reply_message_id
- [x] Fix markdown cache for reply message
- [x] Fix spacing when first message is a reply to you and you want to
reply to that message.
- [x] Fetch message that you replied to
    - [x] allow fetching messages that are not inside the current view 
- [x] When message is deleted, we should show a text like `message
deleted` or something
    - [x] Show correct GitHub username + icon after `Replied to: `
    - [x] Show correct message(now it's hard-coded)
- [x] Add icon to reply + add the onClick logic
- [x] Show message that you want to reply to
  - [x] Allow to click away the message that you want to reply to
  - [x] Fix hard-coded GitHub user + icon after `Reply tp:`
  - [x] Add tests

<img width="242" alt="Screenshot 2024-02-06 at 20 51 40"
src="https://github.com/zed-industries/zed/assets/62463826/a7a5f3e0-dee3-4d38-95db-258b169e4498">
<img width="240" alt="Screenshot 2024-02-06 at 20 52 02"
src="https://github.com/zed-industries/zed/assets/62463826/3e136de3-4135-4c07-bd43-30089b677c0a">


Release Notes:

- Added the ability to reply to a message.
- Added highlight message when you click on mention notifications or a
reply message.

---------

Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/channel/src/channel_chat.rs                               | 171 
crates/channel/src/channel_store_tests.rs                        |   5 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql   |   3 
crates/collab/migrations/20240203113741_add_reply_to_message.sql |   1 
crates/collab/src/db/queries/messages.rs                         |   3 
crates/collab/src/db/tables/channel_message.rs                   |   1 
crates/collab/src/db/tests/message_tests.rs                      |  50 
crates/collab/src/rpc.rs                                         |   5 
crates/collab/src/tests/channel_message_tests.rs                 |  64 
crates/collab_ui/src/chat_panel.rs                               | 352 +
crates/collab_ui/src/chat_panel/message_editor.rs                |  22 
crates/rpc/proto/zed.proto                                       |   2 
12 files changed, 569 insertions(+), 110 deletions(-)

Detailed changes

crates/channel/src/channel_chat.rs πŸ”—

@@ -6,11 +6,12 @@ use client::{
     Client, Subscription, TypedEnvelope, UserId,
 };
 use futures::lock::Mutex;
-use gpui::{AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task};
+use gpui::{
+    AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel,
+};
 use rand::prelude::*;
 use std::{
     collections::HashSet,
-    mem,
     ops::{ControlFlow, Range},
     sync::Arc,
 };
@@ -26,6 +27,7 @@ pub struct ChannelChat {
     loaded_all_messages: bool,
     last_acknowledged_id: Option<u64>,
     next_pending_message_id: usize,
+    first_loaded_message_id: Option<u64>,
     user_store: Model<UserStore>,
     rpc: Arc<Client>,
     outgoing_messages_lock: Arc<Mutex<()>>,
@@ -37,6 +39,7 @@ pub struct ChannelChat {
 pub struct MessageParams {
     pub text: String,
     pub mentions: Vec<(Range<usize>, UserId)>,
+    pub reply_to_message_id: Option<u64>,
 }
 
 #[derive(Clone, Debug)]
@@ -47,6 +50,7 @@ pub struct ChannelMessage {
     pub sender: Arc<User>,
     pub nonce: u128,
     pub mentions: Vec<(Range<usize>, UserId)>,
+    pub reply_to_message_id: Option<u64>,
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -55,6 +59,15 @@ pub enum ChannelMessageId {
     Pending(usize),
 }
 
+impl Into<Option<u64>> for ChannelMessageId {
+    fn into(self) -> Option<u64> {
+        match self {
+            ChannelMessageId::Saved(id) => Some(id),
+            ChannelMessageId::Pending(_) => None,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Default)]
 pub struct ChannelMessageSummary {
     max_id: ChannelMessageId,
@@ -96,28 +109,35 @@ impl ChannelChat {
         let response = client
             .request(proto::JoinChannelChat { channel_id })
             .await?;
-        let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
-        let loaded_all_messages = response.done;
 
-        Ok(cx.new_model(|cx| {
+        let handle = cx.new_model(|cx| {
             cx.on_release(Self::release).detach();
-            let mut this = Self {
+            Self {
                 channel_id: channel.id,
-                user_store,
+                user_store: user_store.clone(),
                 channel_store,
-                rpc: client,
+                rpc: client.clone(),
                 outgoing_messages_lock: Default::default(),
                 messages: Default::default(),
                 acknowledged_message_ids: Default::default(),
-                loaded_all_messages,
+                loaded_all_messages: false,
                 next_pending_message_id: 0,
                 last_acknowledged_id: None,
                 rng: StdRng::from_entropy(),
+                first_loaded_message_id: None,
                 _subscription: subscription.set_model(&cx.handle(), &mut cx.to_async()),
-            };
-            this.insert_messages(messages, cx);
-            this
-        })?)
+            }
+        })?;
+        Self::handle_loaded_messages(
+            handle.downgrade(),
+            user_store,
+            client,
+            response.messages,
+            response.done,
+            &mut cx,
+        )
+        .await?;
+        Ok(handle)
     }
 
     fn release(&mut self, _: &mut AppContext) {
@@ -166,6 +186,7 @@ impl ChannelChat {
                     timestamp: OffsetDateTime::now_utc(),
                     mentions: message.mentions.clone(),
                     nonce,
+                    reply_to_message_id: message.reply_to_message_id,
                 },
                 &(),
             ),
@@ -183,6 +204,7 @@ impl ChannelChat {
                 body: message.text,
                 nonce: Some(nonce.into()),
                 mentions: mentions_to_proto(&message.mentions),
+                reply_to_message_id: message.reply_to_message_id,
             });
             let response = request.await?;
             drop(outgoing_message_guard);
@@ -227,12 +249,16 @@ impl ChannelChat {
                         before_message_id,
                     })
                     .await?;
-                let loaded_all_messages = response.done;
-                let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
-                this.update(&mut cx, |this, cx| {
-                    this.loaded_all_messages = loaded_all_messages;
-                    this.insert_messages(messages, cx);
-                })?;
+                Self::handle_loaded_messages(
+                    this,
+                    user_store,
+                    rpc,
+                    response.messages,
+                    response.done,
+                    &mut cx,
+                )
+                .await?;
+
                 anyhow::Ok(())
             }
             .log_err()
@@ -240,9 +266,14 @@ impl ChannelChat {
     }
 
     pub fn first_loaded_message_id(&mut self) -> Option<u64> {
-        self.messages.first().and_then(|message| match message.id {
-            ChannelMessageId::Saved(id) => Some(id),
-            ChannelMessageId::Pending(_) => None,
+        self.first_loaded_message_id
+    }
+
+    /// Load a message by its id, if it's already stored locally.
+    pub fn find_loaded_message(&self, id: u64) -> Option<&ChannelMessage> {
+        self.messages.iter().find(|message| match message.id {
+            ChannelMessageId::Saved(message_id) => message_id == id,
+            ChannelMessageId::Pending(_) => false,
         })
     }
 
@@ -304,6 +335,66 @@ impl ChannelChat {
         }
     }
 
+    async fn handle_loaded_messages(
+        this: WeakModel<Self>,
+        user_store: Model<UserStore>,
+        rpc: Arc<Client>,
+        proto_messages: Vec<proto::ChannelMessage>,
+        loaded_all_messages: bool,
+        cx: &mut AsyncAppContext,
+    ) -> Result<()> {
+        let loaded_messages = messages_from_proto(proto_messages, &user_store, cx).await?;
+
+        let first_loaded_message_id = loaded_messages.first().map(|m| m.id);
+        let loaded_message_ids = this.update(cx, |this, _| {
+            let mut loaded_message_ids: HashSet<u64> = HashSet::default();
+            for message in loaded_messages.iter() {
+                if let Some(saved_message_id) = message.id.into() {
+                    loaded_message_ids.insert(saved_message_id);
+                }
+            }
+            for message in this.messages.iter() {
+                if let Some(saved_message_id) = message.id.into() {
+                    loaded_message_ids.insert(saved_message_id);
+                }
+            }
+            loaded_message_ids
+        })?;
+
+        let missing_ancestors = loaded_messages
+            .iter()
+            .filter_map(|message| {
+                if let Some(ancestor_id) = message.reply_to_message_id {
+                    if !loaded_message_ids.contains(&ancestor_id) {
+                        return Some(ancestor_id);
+                    }
+                }
+                None
+            })
+            .collect::<Vec<_>>();
+
+        let loaded_ancestors = if missing_ancestors.is_empty() {
+            None
+        } else {
+            let response = rpc
+                .request(proto::GetChannelMessagesById {
+                    message_ids: missing_ancestors,
+                })
+                .await?;
+            Some(messages_from_proto(response.messages, &user_store, cx).await?)
+        };
+        this.update(cx, |this, cx| {
+            this.first_loaded_message_id = first_loaded_message_id.and_then(|msg_id| msg_id.into());
+            this.loaded_all_messages = loaded_all_messages;
+            this.insert_messages(loaded_messages, cx);
+            if let Some(loaded_ancestors) = loaded_ancestors {
+                this.insert_messages(loaded_ancestors, cx);
+            }
+        })?;
+
+        Ok(())
+    }
+
     pub fn rejoin(&mut self, cx: &mut ModelContext<Self>) {
         let user_store = self.user_store.clone();
         let rpc = self.rpc.clone();
@@ -311,28 +402,17 @@ impl ChannelChat {
         cx.spawn(move |this, mut cx| {
             async move {
                 let response = rpc.request(proto::JoinChannelChat { channel_id }).await?;
-                let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
-                let loaded_all_messages = response.done;
-
-                let pending_messages = this.update(&mut cx, |this, cx| {
-                    if let Some((first_new_message, last_old_message)) =
-                        messages.first().zip(this.messages.last())
-                    {
-                        if first_new_message.id > last_old_message.id {
-                            let old_messages = mem::take(&mut this.messages);
-                            cx.emit(ChannelChatEvent::MessagesUpdated {
-                                old_range: 0..old_messages.summary().count,
-                                new_count: 0,
-                            });
-                            this.loaded_all_messages = loaded_all_messages;
-                        }
-                    }
-
-                    this.insert_messages(messages, cx);
-                    if loaded_all_messages {
-                        this.loaded_all_messages = loaded_all_messages;
-                    }
-
+                Self::handle_loaded_messages(
+                    this.clone(),
+                    user_store.clone(),
+                    rpc.clone(),
+                    response.messages,
+                    response.done,
+                    &mut cx,
+                )
+                .await?;
+
+                let pending_messages = this.update(&mut cx, |this, _| {
                     this.pending_messages().cloned().collect::<Vec<_>>()
                 })?;
 
@@ -342,6 +422,7 @@ impl ChannelChat {
                         body: pending_message.body,
                         mentions: mentions_to_proto(&pending_message.mentions),
                         nonce: Some(pending_message.nonce.into()),
+                        reply_to_message_id: pending_message.reply_to_message_id,
                     });
                     let response = request.await?;
                     let message = ChannelMessage::from_proto(
@@ -553,6 +634,7 @@ impl ChannelMessage {
                 .nonce
                 .ok_or_else(|| anyhow!("nonce is required"))?
                 .into(),
+            reply_to_message_id: message.reply_to_message_id,
         })
     }
 
@@ -642,6 +724,7 @@ impl<'a> From<&'a str> for MessageParams {
         Self {
             text: value.into(),
             mentions: Vec::new(),
+            reply_to_message_id: None,
         }
     }
 }

crates/channel/src/channel_store_tests.rs πŸ”—

@@ -184,6 +184,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                     sender_id: 5,
                     mentions: vec![],
                     nonce: Some(1.into()),
+                    reply_to_message_id: None,
                 },
                 proto::ChannelMessage {
                     id: 11,
@@ -192,6 +193,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                     sender_id: 6,
                     mentions: vec![],
                     nonce: Some(2.into()),
+                    reply_to_message_id: None,
                 },
             ],
             done: false,
@@ -239,6 +241,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
             sender_id: 7,
             mentions: vec![],
             nonce: Some(3.into()),
+            reply_to_message_id: None,
         }),
     });
 
@@ -292,6 +295,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                     sender_id: 5,
                     nonce: Some(4.into()),
                     mentions: vec![],
+                    reply_to_message_id: None,
                 },
                 proto::ChannelMessage {
                     id: 9,
@@ -300,6 +304,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                     sender_id: 6,
                     nonce: Some(5.into()),
                     mentions: vec![],
+                    reply_to_message_id: None,
                 },
             ],
         },

crates/collab/migrations.sqlite/20221109000000_test_schema.sql πŸ”—

@@ -217,7 +217,8 @@ CREATE TABLE IF NOT EXISTS "channel_messages" (
     "sender_id" INTEGER NOT NULL REFERENCES users (id),
     "body" TEXT NOT NULL,
     "sent_at" TIMESTAMP,
-    "nonce" BLOB NOT NULL
+    "nonce" BLOB NOT NULL,
+    "reply_to_message_id" INTEGER DEFAULT NULL
 );
 CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id");
 CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce");

crates/collab/src/db/queries/messages.rs πŸ”—

@@ -161,6 +161,7 @@ impl Database {
                         upper_half: nonce.0,
                         lower_half: nonce.1,
                     }),
+                    reply_to_message_id: row.reply_to_message_id.map(|id| id.to_proto()),
                 }
             })
             .collect::<Vec<_>>();
@@ -207,6 +208,7 @@ impl Database {
         mentions: &[proto::ChatMention],
         timestamp: OffsetDateTime,
         nonce: u128,
+        reply_to_message_id: Option<MessageId>,
     ) -> Result<CreatedChannelMessage> {
         self.transaction(|tx| async move {
             let channel = self.get_channel_internal(channel_id, &*tx).await?;
@@ -245,6 +247,7 @@ impl Database {
                 sent_at: ActiveValue::Set(timestamp),
                 nonce: ActiveValue::Set(Uuid::from_u128(nonce)),
                 id: ActiveValue::NotSet,
+                reply_to_message_id: ActiveValue::Set(reply_to_message_id),
             })
             .on_conflict(
                 OnConflict::columns([

crates/collab/src/db/tests/message_tests.rs πŸ”—

@@ -32,6 +32,7 @@ async fn test_channel_message_retrieval(db: &Arc<Database>) {
                 &[],
                 OffsetDateTime::now_utc(),
                 i,
+                None,
             )
             .await
             .unwrap()
@@ -106,6 +107,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
             &mentions_to_proto(&[(3..10, user_b.to_proto())]),
             OffsetDateTime::now_utc(),
             100,
+            None,
         )
         .await
         .unwrap()
@@ -118,6 +120,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
             &mentions_to_proto(&[]),
             OffsetDateTime::now_utc(),
             200,
+            None,
         )
         .await
         .unwrap()
@@ -130,6 +133,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
             &mentions_to_proto(&[(4..11, user_c.to_proto())]),
             OffsetDateTime::now_utc(),
             100,
+            None,
         )
         .await
         .unwrap()
@@ -142,6 +146,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
             &mentions_to_proto(&[]),
             OffsetDateTime::now_utc(),
             200,
+            None,
         )
         .await
         .unwrap()
@@ -157,6 +162,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
             &mentions_to_proto(&[(4..11, user_a.to_proto())]),
             OffsetDateTime::now_utc(),
             100,
+            None,
         )
         .await
         .unwrap()
@@ -231,17 +237,41 @@ async fn test_unseen_channel_messages(db: &Arc<Database>) {
         .unwrap();
 
     let _ = db
-        .create_channel_message(channel_1, user, "1_1", &[], OffsetDateTime::now_utc(), 1)
+        .create_channel_message(
+            channel_1,
+            user,
+            "1_1",
+            &[],
+            OffsetDateTime::now_utc(),
+            1,
+            None,
+        )
         .await
         .unwrap();
 
     let _ = db
-        .create_channel_message(channel_1, user, "1_2", &[], OffsetDateTime::now_utc(), 2)
+        .create_channel_message(
+            channel_1,
+            user,
+            "1_2",
+            &[],
+            OffsetDateTime::now_utc(),
+            2,
+            None,
+        )
         .await
         .unwrap();
 
     let third_message = db
-        .create_channel_message(channel_1, user, "1_3", &[], OffsetDateTime::now_utc(), 3)
+        .create_channel_message(
+            channel_1,
+            user,
+            "1_3",
+            &[],
+            OffsetDateTime::now_utc(),
+            3,
+            None,
+        )
         .await
         .unwrap()
         .message_id;
@@ -251,7 +281,15 @@ async fn test_unseen_channel_messages(db: &Arc<Database>) {
         .unwrap();
 
     let fourth_message = db
-        .create_channel_message(channel_2, user, "2_1", &[], OffsetDateTime::now_utc(), 4)
+        .create_channel_message(
+            channel_2,
+            user,
+            "2_1",
+            &[],
+            OffsetDateTime::now_utc(),
+            4,
+            None,
+        )
         .await
         .unwrap()
         .message_id;
@@ -317,6 +355,7 @@ async fn test_channel_message_mentions(db: &Arc<Database>) {
         &mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
         OffsetDateTime::now_utc(),
         1,
+        None,
     )
     .await
     .unwrap();
@@ -327,6 +366,7 @@ async fn test_channel_message_mentions(db: &Arc<Database>) {
         &mentions_to_proto(&[(4..11, user_c.to_proto())]),
         OffsetDateTime::now_utc(),
         2,
+        None,
     )
     .await
     .unwrap();
@@ -337,6 +377,7 @@ async fn test_channel_message_mentions(db: &Arc<Database>) {
         &mentions_to_proto(&[]),
         OffsetDateTime::now_utc(),
         3,
+        None,
     )
     .await
     .unwrap();
@@ -347,6 +388,7 @@ async fn test_channel_message_mentions(db: &Arc<Database>) {
         &mentions_to_proto(&[(0..7, user_b.to_proto())]),
         OffsetDateTime::now_utc(),
         4,
+        None,
     )
     .await
     .unwrap();

crates/collab/src/rpc.rs πŸ”—

@@ -3019,6 +3019,10 @@ async fn send_channel_message(
             &request.mentions,
             timestamp,
             nonce.clone().into(),
+            match request.reply_to_message_id {
+                Some(reply_to_message_id) => Some(MessageId::from_proto(reply_to_message_id)),
+                None => None,
+            },
         )
         .await?;
     let message = proto::ChannelMessage {
@@ -3028,6 +3032,7 @@ async fn send_channel_message(
         mentions: request.mentions,
         timestamp: timestamp.unix_timestamp() as u64,
         nonce: Some(nonce),
+        reply_to_message_id: request.reply_to_message_id,
     };
     broadcast(
         Some(session.connection_id),

crates/collab/src/tests/channel_message_tests.rs πŸ”—

@@ -43,6 +43,7 @@ async fn test_basic_channel_messages(
                 MessageParams {
                     text: "hi @user_c!".into(),
                     mentions: vec![(3..10, client_c.id())],
+                    reply_to_message_id: None,
                 },
                 cx,
             )
@@ -402,3 +403,66 @@ async fn test_channel_message_changes(
 
     assert!(b_has_messages);
 }
+
+#[gpui::test]
+async fn test_chat_replies(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let channel_id = server
+        .make_channel(
+            "the-channel",
+            None,
+            (&client_a, cx_a),
+            &mut [(&client_b, cx_b)],
+        )
+        .await;
+
+    // Client A sends a message, client B should see that there is a new message.
+    let channel_chat_a = client_a
+        .channel_store()
+        .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
+        .await
+        .unwrap();
+
+    let channel_chat_b = client_b
+        .channel_store()
+        .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
+        .await
+        .unwrap();
+
+    let msg_id = channel_chat_a
+        .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
+        .await
+        .unwrap();
+
+    cx_a.run_until_parked();
+
+    let reply_id = channel_chat_b
+        .update(cx_b, |c, cx| {
+            c.send_message(
+                MessageParams {
+                    text: "reply".into(),
+                    reply_to_message_id: Some(msg_id),
+                    mentions: Vec::new(),
+                },
+                cx,
+            )
+            .unwrap()
+        })
+        .await
+        .unwrap();
+
+    cx_a.run_until_parked();
+
+    channel_chat_a.update(cx_a, |channel_chat, _| {
+        assert_eq!(
+            channel_chat
+                .find_loaded_message(reply_id)
+                .unwrap()
+                .reply_to_message_id,
+            Some(msg_id),
+        )
+    });
+}

crates/collab_ui/src/chat_panel.rs πŸ”—

@@ -1,16 +1,16 @@
 use crate::{collab_panel, ChatPanelSettings};
 use anyhow::Result;
 use call::{room, ActiveCall};
-use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
+use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, ChannelStore};
 use client::Client;
 use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use gpui::{
-    actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, DismissEvent,
-    ElementId, EventEmitter, Fill, FocusHandle, FocusableView, FontWeight, ListOffset,
-    ListScrollEvent, ListState, Model, Render, Subscription, Task, View, ViewContext,
-    VisualContext, WeakView,
+    actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, CursorStyle,
+    DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight,
+    HighlightStyle, ListOffset, ListScrollEvent, ListState, Model, Render, StyledText,
+    Subscription, Task, View, ViewContext, VisualContext, WeakView,
 };
 use language::LanguageRegistry;
 use menu::Confirm;
@@ -23,7 +23,7 @@ use std::{sync::Arc, time::Duration};
 use time::{OffsetDateTime, UtcOffset};
 use ui::{
     popover_menu, prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label,
-    TabBar,
+    TabBar, Tooltip,
 };
 use util::{ResultExt, TryFutureExt};
 use workspace::{
@@ -62,6 +62,7 @@ pub struct ChatPanel {
     markdown_data: HashMap<ChannelMessageId, RichText>,
     focus_handle: FocusHandle,
     open_context_menu: Option<(u64, Subscription)>,
+    highlighted_message: Option<(u64, Task<()>)>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -124,6 +125,7 @@ impl ChatPanel {
                 markdown_data: Default::default(),
                 focus_handle: cx.focus_handle(),
                 open_context_menu: None,
+                highlighted_message: None,
             };
 
             if let Some(channel_id) = ActiveCall::global(cx)
@@ -236,6 +238,7 @@ impl ChatPanel {
                 let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
                 self.message_editor.update(cx, |editor, cx| {
                     editor.set_channel(channel_id, channel_name, cx);
+                    editor.clear_reply_to_message_id();
                 });
             };
             let subscription = cx.subscribe(&chat, Self::channel_did_change);
@@ -285,6 +288,99 @@ impl ChatPanel {
         }
     }
 
+    fn render_replied_to_message(
+        &mut self,
+        message_id: Option<ChannelMessageId>,
+        reply_to_message: &ChannelMessage,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let body_element_id: ElementId = match message_id {
+            Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message", id).into(),
+            Some(ChannelMessageId::Pending(id)) => ("reply-to-pending-message", id).into(), // This should never happen
+            None => ("composing-reply").into(),
+        };
+
+        let message_element_id: ElementId = match message_id {
+            Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message-container", id).into(),
+            Some(ChannelMessageId::Pending(id)) => {
+                ("reply-to-pending-message-container", id).into()
+            } // This should never happen
+            None => ("composing-reply-container").into(),
+        };
+
+        let current_channel_id = self.channel_id(cx);
+        let reply_to_message_id = reply_to_message.id;
+
+        let reply_to_message_body = self
+            .markdown_data
+            .entry(reply_to_message.id)
+            .or_insert_with(|| {
+                Self::render_markdown_with_mentions(
+                    &self.languages,
+                    self.client.id(),
+                    reply_to_message,
+                )
+            });
+
+        const REPLY_TO_PREFIX: &str = "Reply to @";
+
+        div().flex_grow().child(
+            v_flex()
+                .id(message_element_id)
+                .text_ui_xs()
+                .child(
+                    h_flex()
+                        .gap_x_1()
+                        .items_center()
+                        .justify_start()
+                        .overflow_x_hidden()
+                        .whitespace_nowrap()
+                        .child(
+                            StyledText::new(format!(
+                                "{}{}",
+                                REPLY_TO_PREFIX,
+                                reply_to_message.sender.github_login.clone()
+                            ))
+                            .with_highlights(
+                                &cx.text_style(),
+                                vec![(
+                                    (REPLY_TO_PREFIX.len() - 1)
+                                        ..(reply_to_message.sender.github_login.len()
+                                            + REPLY_TO_PREFIX.len()),
+                                    HighlightStyle {
+                                        font_weight: Some(FontWeight::BOLD),
+                                        ..Default::default()
+                                    },
+                                )],
+                            ),
+                        ),
+                )
+                .child(
+                    div()
+                        .border_l_2()
+                        .border_color(cx.theme().colors().border)
+                        .px_1()
+                        .py_0p5()
+                        .mb_1()
+                        .overflow_hidden()
+                        .child(
+                            div()
+                                .max_h_12()
+                                .child(reply_to_message_body.element(body_element_id, cx)),
+                        ),
+                )
+                .cursor(CursorStyle::PointingHand)
+                .tooltip(|cx| Tooltip::text("Go to message", cx))
+                .on_click(cx.listener(move |chat_panel, _, cx| {
+                    if let Some(channel_id) = current_channel_id {
+                        chat_panel
+                            .select_channel(channel_id, reply_to_message_id.into(), cx)
+                            .detach_and_log_err(cx)
+                    }
+                })),
+        )
+    }
+
     fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let active_chat = &self.active_chat.as_ref().unwrap().0;
         let (message, is_continuation_from_previous, is_admin) =
@@ -317,18 +413,9 @@ impl ChatPanel {
             });
 
         let _is_pending = message.is_pending();
-        let text = self.markdown_data.entry(message.id).or_insert_with(|| {
-            Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
-        });
 
         let belongs_to_user = Some(message.sender.id) == self.client.user_id();
-        let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
-            (message.id, belongs_to_user || is_admin)
-        {
-            Some(id)
-        } else {
-            None
-        };
+        let can_delete_message = belongs_to_user || is_admin;
 
         let element_id: ElementId = match message.id {
             ChannelMessageId::Saved(id) => ("saved-message", id).into(),
@@ -341,19 +428,41 @@ impl ChatPanel {
             .iter()
             .any(|m| Some(m.1) == self.client.user_id());
 
+        let message_id = match message.id {
+            ChannelMessageId::Saved(id) => Some(id),
+            ChannelMessageId::Pending(_) => None,
+        };
+
+        let reply_to_message = message
+            .reply_to_message_id
+            .map(|id| active_chat.read(cx).find_loaded_message(id))
+            .flatten()
+            .cloned();
+
+        let replied_to_you =
+            reply_to_message.as_ref().map(|m| m.sender.id) == self.client.user_id();
+
+        let is_highlighted_message = self
+            .highlighted_message
+            .as_ref()
+            .is_some_and(|(id, _)| Some(id) == message_id.as_ref());
+        let background = if is_highlighted_message {
+            cx.theme().status().info_background
+        } else if mentioning_you || replied_to_you {
+            cx.theme().colors().background
+        } else {
+            cx.theme().colors().panel_background
+        };
+
         v_flex().w_full().relative().child(
             div()
-                .bg(if mentioning_you {
-                    Fill::from(cx.theme().colors().background)
-                } else {
-                    Fill::default()
-                })
+                .bg(background)
                 .rounded_md()
                 .overflow_hidden()
                 .px_1()
                 .py_0p5()
                 .when(!is_continuation_from_previous, |this| {
-                    this.mt_1().child(
+                    this.mt_2().child(
                         h_flex()
                             .text_ui_sm()
                             .child(div().absolute().child(
@@ -377,36 +486,86 @@ impl ChatPanel {
                             ),
                     )
                 })
-                .when(mentioning_you, |this| this.mt_1())
-                .child(
-                    v_flex()
-                        .w_full()
-                        .text_ui_sm()
-                        .id(element_id)
-                        .group("")
-                        .child(text.element("body".into(), cx))
-                        .child(
+                .when(
+                    message.reply_to_message_id.is_some() && reply_to_message.is_none(),
+                    |this| {
+                        const MESSAGE_DELETED: &str = "Message has been deleted";
+
+                        let body_text = StyledText::new(MESSAGE_DELETED).with_highlights(
+                            &cx.text_style(),
+                            vec![(
+                                0..MESSAGE_DELETED.len(),
+                                HighlightStyle {
+                                    font_style: Some(FontStyle::Italic),
+                                    ..Default::default()
+                                },
+                            )],
+                        );
+
+                        this.child(
                             div()
-                                .absolute()
-                                .z_index(1)
-                                .right_0()
-                                .w_6()
-                                .bg(cx.theme().colors().panel_background)
-                                .when(!self.has_open_menu(message_id_to_remove), |el| {
-                                    el.visible_on_hover("")
-                                })
-                                .children(message_id_to_remove.map(|message_id| {
-                                    popover_menu(("menu", message_id))
-                                        .trigger(IconButton::new(
-                                            ("trigger", message_id),
-                                            IconName::Ellipsis,
-                                        ))
-                                        .menu(move |cx| {
-                                            Some(Self::render_message_menu(&this, message_id, cx))
-                                        })
-                                })),
-                        ),
-                ),
+                                .border_l_2()
+                                .text_ui_xs()
+                                .border_color(cx.theme().colors().border)
+                                .px_1()
+                                .py_0p5()
+                                .child(body_text),
+                        )
+                    },
+                )
+                .when_some(reply_to_message, |el, reply_to_message| {
+                    el.child(self.render_replied_to_message(
+                        Some(message.id),
+                        &reply_to_message,
+                        cx,
+                    ))
+                })
+                .when(mentioning_you || replied_to_you, |this| this.my_0p5())
+                .map(|el| {
+                    let text = self.markdown_data.entry(message.id).or_insert_with(|| {
+                        Self::render_markdown_with_mentions(
+                            &self.languages,
+                            self.client.id(),
+                            &message,
+                        )
+                    });
+                    el.child(
+                        v_flex()
+                            .w_full()
+                            .text_ui_sm()
+                            .id(element_id)
+                            .group("")
+                            .child(text.element("body".into(), cx))
+                            .child(
+                                div()
+                                    .absolute()
+                                    .z_index(1)
+                                    .right_0()
+                                    .w_6()
+                                    .bg(background)
+                                    .when(!self.has_open_menu(message_id), |el| {
+                                        el.visible_on_hover("")
+                                    })
+                                    .when_some(message_id, |el, message_id| {
+                                        el.child(
+                                            popover_menu(("menu", message_id))
+                                                .trigger(IconButton::new(
+                                                    ("trigger", message_id),
+                                                    IconName::Ellipsis,
+                                                ))
+                                                .menu(move |cx| {
+                                                    Some(Self::render_message_menu(
+                                                        &this,
+                                                        message_id,
+                                                        can_delete_message,
+                                                        cx,
+                                                    ))
+                                                }),
+                                        )
+                                    }),
+                            ),
+                    )
+                }),
         )
     }
 
@@ -420,13 +579,27 @@ impl ChatPanel {
     fn render_message_menu(
         this: &View<Self>,
         message_id: u64,
+        can_delete_message: bool,
         cx: &mut WindowContext,
     ) -> View<ContextMenu> {
         let menu = {
-            let this = this.clone();
-            ContextMenu::build(cx, move |menu, _| {
-                menu.entry("Delete message", None, move |cx| {
-                    this.update(cx, |this, cx| this.remove_message(message_id, cx))
+            ContextMenu::build(cx, move |menu, cx| {
+                menu.entry(
+                    "Reply to message",
+                    None,
+                    cx.handler_for(&this, move |this, cx| {
+                        this.message_editor.update(cx, |editor, cx| {
+                            editor.set_reply_to_message_id(message_id);
+                            editor.focus_handle(cx).focus(cx);
+                        })
+                    }),
+                )
+                .when(can_delete_message, move |menu| {
+                    menu.entry(
+                        "Delete message",
+                        None,
+                        cx.handler_for(&this, move |this, cx| this.remove_message(message_id, cx)),
+                    )
                 })
             })
         };
@@ -517,7 +690,21 @@ impl ChatPanel {
                     ChannelChat::load_history_since_message(chat.clone(), message_id, (*cx).clone())
                         .await
                 {
+                    let task = cx.spawn({
+                        let this = this.clone();
+
+                        |mut cx| async move {
+                            cx.background_executor().timer(Duration::from_secs(2)).await;
+                            this.update(&mut cx, |this, cx| {
+                                this.highlighted_message.take();
+                                cx.notify();
+                            })
+                            .ok();
+                        }
+                    });
+
                     this.update(&mut cx, |this, cx| {
+                        this.highlighted_message = Some((message_id, task));
                         if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
                             this.message_list.scroll_to(ListOffset {
                                 item_ix,
@@ -536,6 +723,8 @@ impl ChatPanel {
 
 impl Render for ChatPanel {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let reply_to_message_id = self.message_editor.read(cx).reply_to_message_id();
+
         v_flex()
             .track_focus(&self.focus_handle)
             .full()
@@ -558,7 +747,7 @@ impl Render for ChatPanel {
                     ),
                 ),
             )
-            .child(div().flex_grow().px_2().pt_1().map(|this| {
+            .child(div().flex_grow().px_2().map(|this| {
                 if self.active_chat.is_some() {
                     this.child(list(self.message_list.clone()).full())
                 } else {
@@ -589,14 +778,56 @@ impl Render for ChatPanel {
                     )
                 }
             }))
+            .when_some(reply_to_message_id, |el, reply_to_message_id| {
+                let reply_message = self
+                    .active_chat()
+                    .map(|active_chat| {
+                        active_chat.read(cx).messages().iter().find_map(|m| {
+                            if m.id == ChannelMessageId::Saved(reply_to_message_id) {
+                                Some(m)
+                            } else {
+                                None
+                            }
+                        })
+                    })
+                    .flatten()
+                    .cloned();
+
+                el.when_some(reply_message, |el, reply_message| {
+                    el.child(
+                        div()
+                            .when(!self.is_scrolled_to_bottom, |el| {
+                                el.border_t_1().border_color(cx.theme().colors().border)
+                            })
+                            .flex()
+                            .w_full()
+                            .items_start()
+                            .overflow_hidden()
+                            .py_1()
+                            .px_2()
+                            .bg(cx.theme().colors().background)
+                            .child(self.render_replied_to_message(None, &reply_message, cx))
+                            .child(
+                                IconButton::new("close-reply-preview", IconName::Close)
+                                    .shape(ui::IconButtonShape::Square)
+                                    .on_click(cx.listener(move |this, _, cx| {
+                                        this.message_editor.update(cx, |editor, _| {
+                                            editor.clear_reply_to_message_id()
+                                        });
+                                    })),
+                            ),
+                    )
+                })
+            })
             .children(
                 Some(
                     h_flex()
-                        .when(!self.is_scrolled_to_bottom, |el| {
-                            el.border_t_1().border_color(cx.theme().colors().border)
-                        })
+                        .when(
+                            !self.is_scrolled_to_bottom && reply_to_message_id.is_none(),
+                            |el| el.border_t_1().border_color(cx.theme().colors().border),
+                        )
                         .p_2()
-                        .child(self.message_editor.clone()),
+                        .map(|el| el.child(self.message_editor.clone())),
                 )
                 .filter(|_| self.active_chat.is_some()),
             )
@@ -738,6 +969,7 @@ mod tests {
             }),
             nonce: 5,
             mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+            reply_to_message_id: None,
         };
 
         let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);

crates/collab_ui/src/chat_panel/message_editor.rs πŸ”—

@@ -34,6 +34,7 @@ pub struct MessageEditor {
     mentions: Vec<UserId>,
     mentions_task: Option<Task<()>>,
     channel_id: Option<ChannelId>,
+    reply_to_message_id: Option<u64>,
 }
 
 struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
@@ -112,9 +113,22 @@ impl MessageEditor {
             channel_id: None,
             mentions: Vec::new(),
             mentions_task: None,
+            reply_to_message_id: None,
         }
     }
 
+    pub fn reply_to_message_id(&self) -> Option<u64> {
+        self.reply_to_message_id
+    }
+
+    pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
+        self.reply_to_message_id = Some(reply_to_message_id);
+    }
+
+    pub fn clear_reply_to_message_id(&mut self) {
+        self.reply_to_message_id = None;
+    }
+
     pub fn set_channel(
         &mut self,
         channel_id: u64,
@@ -172,8 +186,13 @@ impl MessageEditor {
 
             editor.clear(cx);
             self.mentions.clear();
+            let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
 
-            MessageParams { text, mentions }
+            MessageParams {
+                text,
+                mentions,
+                reply_to_message_id,
+            }
         })
     }
 
@@ -424,6 +443,7 @@ mod tests {
                 MessageParams {
                     text,
                     mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+                    reply_to_message_id: None
                 }
             );
         });

crates/rpc/proto/zed.proto πŸ”—

@@ -1122,6 +1122,7 @@ message SendChannelMessage {
     string body = 2;
     Nonce nonce = 3;
     repeated ChatMention mentions = 4;
+    optional uint64 reply_to_message_id = 5;
 }
 
 message RemoveChannelMessage {
@@ -1173,6 +1174,7 @@ message ChannelMessage {
     uint64 sender_id = 4;
     Nonce nonce = 5;
     repeated ChatMention mentions = 6;
+    optional uint64 reply_to_message_id = 7;
 }
 
 message ChatMention {