diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js
index f1da2a1dd17..14657f390dc 100644
--- a/app/assets/javascripts/discourse/app/components/emoji-picker.js
+++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js
@@ -277,7 +277,7 @@ export default Component.extend({
       if (fromTopicComposer) {
         document.querySelector(".d-editor-input")?.focus();
       } else if (fromChatComposer) {
-        document.querySelector(".chat-composer-input")?.focus();
+        document.querySelector(".chat-composer__input")?.focus();
       } else {
         document.querySelector("textarea")?.focus();
       }
diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js
index 6c4bfc1a2be..dbd999a65e2 100644
--- a/app/assets/javascripts/discourse/app/lib/autocomplete.js
+++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js
@@ -216,7 +216,7 @@ export default function (options) {
       });
   }
 
-  let completeTerm = async function (term) {
+  let completeTerm = async function (term, event) {
     let completeEnd = null;
 
     if (term) {
@@ -228,7 +228,7 @@ export default function (options) {
         addInputSelectedItem(term, true);
       } else {
         if (options.transformComplete) {
-          term = await options.transformComplete(term);
+          term = await options.transformComplete(term, event);
         }
 
         if (term) {
@@ -272,7 +272,7 @@ export default function (options) {
           setCaretPosition(me[0], newCaretPos);
 
           if (options && options.afterComplete) {
-            options.afterComplete(text);
+            options.afterComplete(text, event);
           }
         }
       }
@@ -371,7 +371,7 @@ export default function (options) {
     } else {
       selectedOption = -1;
     }
-    ul.find("li").click(function () {
+    ul.find("li").click(function ({ originalEvent }) {
       selectedOption = ul.find("li").index(this);
       // hack for Gboard, see meta.discourse.org/t/-/187009/24
       if (autocompleteOptions == null) {
@@ -379,13 +379,13 @@ export default function (options) {
         const forcedAutocompleteOptions = dataSource(prevTerm, opts);
         forcedAutocompleteOptions?.then((data) => {
           updateAutoComplete(data);
-          completeTerm(autocompleteOptions[selectedOption]);
+          completeTerm(autocompleteOptions[selectedOption], originalEvent);
           if (!options.single) {
             me.focus();
           }
         });
       } else {
-        completeTerm(autocompleteOptions[selectedOption]);
+        completeTerm(autocompleteOptions[selectedOption], originalEvent);
         if (!options.single) {
           me.focus();
         }
@@ -710,7 +710,7 @@ export default function (options) {
             selectedOption >= 0 &&
             (userToComplete = autocompleteOptions[selectedOption])
           ) {
-            completeTerm(userToComplete);
+            completeTerm(userToComplete, e);
           } else {
             // We're cancelling it, really.
             return true;
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
similarity index 84%
rename from plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs
rename to plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
index 5681ea0b925..083ce749674 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
@@ -1,10 +1,11 @@
 <div
   class={{concat-class
-    "chat-live-pane"
+    "chat-channel"
     (if this.loading "loading")
-    (if this.chatChannelPane.sendingLoading "sending-loading")
-    (unless this.loadedOnce "not-loaded-once")
+    (if this.chatChannelPane.sending "chat-channel--sending")
+    (unless this.loadedOnce "chat-channel--not-loaded-once")
   }}
+  {{did-insert this.setUploadDropZone}}
   {{did-insert this.setupListeners}}
   {{will-destroy this.teardownListeners}}
   {{did-insert this.updateChannel}}
@@ -33,7 +34,7 @@
   >
     <div
       class="chat-messages-container"
-      {{chat/on-resize this.didResizePane (hash delay=10)}}
+      {{chat/on-resize this.didResizePane (hash delay=25 immediate=true)}}
     >
       {{#if this.loadedOnce}}
         {{#each @channel.messages key="id" as |message|}}
@@ -78,15 +79,15 @@
     />
   {{else}}
     {{#if (or @channel.isDraft @channel.isFollowing)}}
-      <ChatComposer
-        @sendMessage={{this.sendMessage}}
-        @chatChannel={{@channel}}
-        @composerService={{this.chatChannelComposer}}
-        @paneService={{this.chatChannelPane}}
-        @context="channel"
+      <Chat::Composer::Channel
+        @channel={{@channel}}
+        @uploadDropZone={{this.uploadDropZone}}
+        @onSendMessage={{this.onSendMessage}}
       />
     {{else}}
       <ChatChannelPreviewCard @channel={{@channel}} />
     {{/if}}
   {{/if}}
+
+  <ChatUploadDropZone @model={{@channel}} />
 </div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js
similarity index 91%
rename from plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js
rename to plugins/chat/assets/javascripts/discourse/components/chat-channel.js
index 37b91e116b9..521ca5c12e8 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js
@@ -1,7 +1,5 @@
 import { capitalize } from "@ember/string";
-import { cloneJSON } from "discourse-common/lib/object";
 import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
-import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
 import Component from "@glimmer/component";
 import { bind, debounce } from "discourse-common/utils/decorators";
 import { action } from "@ember/object";
@@ -47,12 +45,13 @@ export default class ChatLivePane extends Component {
   @tracked loading = false;
   @tracked loadingMorePast = false;
   @tracked loadingMoreFuture = false;
-  @tracked sendingLoading = false;
+  @tracked sending = false;
   @tracked showChatQuoteSuccess = false;
   @tracked includeHeader = true;
   @tracked hasNewMessages = false;
   @tracked needsArrow = false;
   @tracked loadedOnce = false;
+  @tracked uploadDropZone;
 
   scrollable = null;
   _loadedChannelId = null;
@@ -60,6 +59,11 @@ export default class ChatLivePane extends Component {
   _unreachableGroupMentions = [];
   _overMembersLimitGroupMentions = [];
 
+  @action
+  setUploadDropZone(element) {
+    this.uploadDropZone = element;
+  }
+
   @action
   setScrollable(element) {
     this.scrollable = element;
@@ -107,7 +111,12 @@ export default class ChatLivePane extends Component {
     if (this._loadedChannelId !== this.args.channel?.id) {
       this._unsubscribeToUpdates(this._loadedChannelId);
       this.chatChannelPane.selectingMessages = false;
-      this.chatChannelComposer.cancelEditing();
+      this.chatChannelComposer.message =
+        this.args.channel.draft ||
+        ChatMessage.createDraftMessage(this.args.channel, {
+          user: this.currentUser,
+        });
+
       this._loadedChannelId = this.args.channel?.id;
     }
 
@@ -568,15 +577,41 @@ export default class ChatLivePane extends Component {
   }
 
   @action
-  sendMessage(message, uploads = []) {
-    resetIdle();
-
-    if (this.chatChannelPane.sendingLoading) {
-      return;
+  onSendMessage(message) {
+    if (message.editing) {
+      this.#sendEditMessage(message);
+    } else {
+      this.#sendNewMessage(message);
     }
+  }
 
-    this.chatChannelPane.sendingLoading = true;
-    this.args.channel.draft = ChatMessageDraft.create();
+  @action
+  resetComposer() {
+    this.chatChannelComposer.reset(this.args.channel);
+  }
+
+  #sendEditMessage(message) {
+    this.chatChannelPane.sending = true;
+
+    const data = {
+      new_message: message.message,
+      upload_ids: message.uploads.map((upload) => upload.id),
+    };
+
+    this.resetComposer();
+
+    return this.chatApi
+      .editMessage(this.args.channel.id, message.id, data)
+      .catch(popupAjaxError)
+      .finally(() => {
+        this.chatChannelPane.sending = false;
+      });
+  }
+
+  #sendNewMessage(message) {
+    this.chatChannelPane.sending = true;
+
+    resetIdle();
 
     // TODO: all send message logic is due for massive refactoring
     // This is all the possible case Im currently aware of
@@ -587,52 +622,38 @@ export default class ChatLivePane extends Component {
     // - message to a public channel you were tracking (preview = false, not draft)
     // - message to a channel when we haven't loaded all future messages yet.
     if (!this.args.channel.isFollowing || this.args.channel.isDraft) {
-      this.loading = true;
+      const data = {
+        message: message.message,
+        upload_ids: message.uploads.map((upload) => upload.id),
+      };
 
-      return this._upsertChannelWithMessage(
-        this.args.channel,
-        message,
-        uploads
-      ).finally(() => {
-        if (this._selfDeleted) {
-          return;
+      this.resetComposer();
+
+      return this._upsertChannelWithMessage(this.args.channel, data).finally(
+        () => {
+          if (this._selfDeleted) {
+            return;
+          }
+          this.chatChannelPane.sending = false;
+          this.scrollToLatestMessage();
         }
-        this.loading = false;
-        this.chatChannelPane.sendingLoading = false;
-        this.chatChannelPane.resetAfterSend();
-        this.scrollToLatestMessage();
-      });
+      );
     }
 
-    const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, {
-      message,
-      created_at: moment.utc().format(),
-      uploads: cloneJSON(uploads),
-      user: this.currentUser,
-    });
+    this.args.channel.stageMessage(message);
+    const stagedMessage = message;
+    this.resetComposer();
 
-    if (this.chatChannelComposer.replyToMsg) {
-      stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg;
-    }
-
-    if (stagedMessage.inReplyTo) {
-      if (!this.args.channel.threadingEnabled) {
-        this.#messagesManager.addMessages([stagedMessage]);
-      }
-    } else {
-      this.#messagesManager.addMessages([stagedMessage]);
-    }
-
-    if (!this.#messagesManager.canLoadMoreFuture) {
+    if (!this.args.channel.canLoadMoreFuture) {
       this.scrollToLatestMessage();
     }
 
     return this.chatApi
       .sendMessage(this.args.channel.id, {
-        message: stagedMessage.message,
-        in_reply_to_id: stagedMessage.inReplyTo?.id,
-        staged_id: stagedMessage.id,
-        upload_ids: stagedMessage.uploads.map((upload) => upload.id),
+        message: message.message,
+        in_reply_to_id: message.inReplyTo?.id,
+        staged_id: message.id,
+        upload_ids: message.uploads.map((upload) => upload.id),
       })
       .then(() => {
         this.scrollToLatestMessage();
@@ -645,12 +666,13 @@ export default class ChatLivePane extends Component {
         if (this._selfDeleted) {
           return;
         }
-        this.chatChannelPane.sendingLoading = false;
-        this.chatChannelPane.resetAfterSend();
+
+        this.args.channel.draft = null;
+        this.chatChannelPane.sending = false;
       });
   }
 
-  async _upsertChannelWithMessage(channel, message, uploads) {
+  async _upsertChannelWithMessage(channel, data) {
     let promise = Promise.resolve(channel);
 
     if (channel.isDirectMessageChannel || channel.isDraft) {
@@ -662,11 +684,9 @@ export default class ChatLivePane extends Component {
     return promise.then((c) =>
       ajax(`/chat/${c.id}.json`, {
         type: "POST",
-        data: {
-          message,
-          upload_ids: (uploads || []).mapBy("id"),
-        },
+        data,
       }).then(() => {
+        this.chatChannelPane.sending = false;
         this.router.transitionTo("chat.channel", "-", c.id);
       })
     );
@@ -686,12 +706,12 @@ export default class ChatLivePane extends Component {
       }
     }
 
-    this.chatChannelPane.resetAfterSend();
+    this.resetComposer();
   }
 
   @action
   resendStagedMessage(stagedMessage) {
-    this.chatChannelPane.sendingLoading = true;
+    this.chatChannelPane.sending = true;
 
     stagedMessage.error = null;
 
@@ -714,7 +734,7 @@ export default class ChatLivePane extends Component {
         if (this._selfDeleted) {
           return;
         }
-        this.chatChannelPane.sendingLoading = false;
+        this.chatChannelPane.sending = false;
       });
   }
 
@@ -824,7 +844,7 @@ export default class ChatLivePane extends Component {
       return;
     }
 
-    const composer = document.querySelector(".chat-composer-input");
+    const composer = document.querySelector(".chat-composer__input");
     if (composer && !this.args.channel.isDraft) {
       composer.focus();
       return;
@@ -836,7 +856,7 @@ export default class ChatLivePane extends Component {
 
   @action
   computeDatesSeparators() {
-    throttle(this, this._computeDatesSeparators, 50, false);
+    throttle(this, this._computeDatesSeparators, 50, true);
   }
 
   // A more consistent way to scroll to the bottom when we are sure this is our goal
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs
index 37763906d32..52e76b40c5d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs
@@ -1,16 +1,16 @@
 <div class="chat-composer-message-details">
   <div class="chat-reply">
-    {{d-icon this.icon}}
-    <ChatUserAvatar @user={{this.message.user}} />
-    <span class="chat-reply__username">{{this.message.user.username}}</span>
+    {{d-icon @icon}}
+    <ChatUserAvatar @user={{@message.user}} />
+    <span class="chat-reply__username">{{@message.user.username}}</span>
     <span class="chat-reply__excerpt">
-      {{replace-emoji this.message.excerpt}}
+      {{replace-emoji @message.excerpt}}
     </span>
   </div>
 
-  <FlatButton
-    @action={{this.action}}
-    @class="cancel-message-action"
+  <DButton
+    @action={{@cancelAction}}
+    @class="btn-flat cancel-message-action"
     @icon="times-circle"
     @title="cancel"
   />
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js
index 44494409ab5..dc169dc4936 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js
@@ -1,5 +1,3 @@
-import Component from "@ember/component";
+import Component from "@glimmer/component";
 
-export default Component.extend({
-  tagName: "",
-});
+export default class ChatComposerMessageDetails extends Component {}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs
index bf1f84e5c6e..b5bbfb7130b 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs
@@ -1,44 +1,57 @@
-<span class="chat-composer-upload">
-  <span class="preview">
-    {{#if (eq this.type this.IMAGE_TYPE)}}
-      {{#if this.isDone}}
-        <img class="preview-img" src={{this.upload.short_path}} />
-      {{else}}
-        {{d-icon "far-image"}}
-      {{/if}}
-    {{else}}
-      {{d-icon "file-alt"}}
-    {{/if}}
-  </span>
-
-  <span class="data">
-    <div class="top-data">
-      <span class="file-name">{{this.fileName}}</span>
-      <DButton
-        @class="btn-flat remove-upload"
-        @action={{this.onCancel}}
-        @icon="times"
-        @title="chat.remove_upload"
-      />
-    </div>
-
-    <div class="bottom-data">
-      {{#if this.isDone}}
-        <span class="extension-pill">{{this.upload.extension}}</span>
-      {{else}}
-        {{#if this.upload.processing}}
-          <span class="processing">{{i18n "processing"}}</span>
+{{#if @upload}}
+  <div
+    class={{concat-class
+      "chat-composer-upload"
+      (if this.isImage "chat-composer-upload--image")
+      (unless @isDone "chat-composer-upload--in-progress")
+    }}
+  >
+    <div class="preview">
+      {{#if this.isImage}}
+        {{#if @isDone}}
+          <img class="preview-img" src={{@upload.short_path}} />
         {{else}}
-          <span class="uploading">{{i18n "uploading"}}</span>
+          {{d-icon "far-image"}}
         {{/if}}
-
-        <progress
-          class="upload-progress"
-          id="file"
-          max="100"
-          value={{this.upload.progress}}
-        ></progress>
+      {{else}}
+        {{d-icon "file-alt"}}
       {{/if}}
     </div>
-  </span>
-</span>
\ No newline at end of file
+
+    <span class="data">
+      {{#unless this.isImage}}
+        <div class="top-data">
+          <span class="file-name">{{this.fileName}}</span>
+        </div>
+      {{/unless}}
+
+      <div class="bottom-data">
+        {{#if @isDone}}
+          {{#unless this.isImage}}
+            <span class="extension-pill">{{@upload.extension}}</span>
+          {{/unless}}
+        {{else}}
+          {{#if @upload.processing}}
+            <span class="processing">{{i18n "processing"}}</span>
+          {{else}}
+            <span class="uploading">{{i18n "uploading"}}</span>
+          {{/if}}
+
+          <progress
+            class="upload-progress"
+            id="file"
+            max="100"
+            value={{@upload.progress}}
+          ></progress>
+        {{/if}}
+      </div>
+    </span>
+
+    <DButton
+      @class="btn-flat chat-composer-upload__remove-btn"
+      @action={{@onCancel}}
+      @icon="times"
+      @title="chat.remove_upload"
+    />
+  </div>
+{{/if}}
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js
index a2a89a2119c..ca2280cf67a 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js
@@ -1,25 +1,16 @@
-import Component from "@ember/component";
-import discourseComputed from "discourse-common/utils/decorators";
+import Component from "@glimmer/component";
 import { isImage } from "discourse/lib/uploads";
 
-export default Component.extend({
-  IMAGE_TYPE: "image",
+export default class ChatComposerUpload extends Component {
+  get isImage() {
+    return isImage(
+      this.args.upload.original_filename || this.args.upload.fileName
+    );
+  }
 
-  tagName: "",
-  classNames: "chat-upload",
-  isDone: false,
-  upload: null,
-  onCancel: null,
-
-  @discourseComputed("upload.{original_filename,fileName}")
-  type(upload) {
-    if (isImage(upload.original_filename || upload.fileName)) {
-      return this.IMAGE_TYPE;
-    }
-  },
-
-  @discourseComputed("isDone", "upload.{original_filename,fileName}")
-  fileName(isDone, upload) {
-    return isDone ? upload.original_filename : upload.fileName;
-  },
-});
+  get fileName() {
+    return this.args.isDone
+      ? this.args.upload.original_filename
+      : this.args.upload.fileName;
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs
index 8bb20bf6d23..2f07ecc996f 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs
@@ -21,19 +21,4 @@
   @allowMultiple={{true}}
   @fileInputId={{this.fileUploadElementId}}
   @fileInputClass="hidden-upload-field"
-/>
-
-<div class="drop-a-file">
-  <div class="drop-a-file-content">
-    <div class="drop-a-file-content-images">
-      {{d-icon "file-audio"}}
-      {{d-icon "file-video"}}
-      {{d-icon "file-image"}}
-    </div>
-
-    <p class="drop-a-file-content-text">
-      {{d-icon "upload"}}
-      Drop a file to upload it.
-    </p>
-  </div>
-</div>
\ No newline at end of file
+/>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
index a6b7f6568fc..6ecd680cbb0 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
@@ -16,6 +16,7 @@ export default Component.extend(UppyUploadMixin, {
   existingUploads: null,
   uploads: null,
   useMultipartUploadsIfAvailable: true,
+  uploadDropZone: null,
 
   init() {
     this._super(...arguments);
@@ -38,7 +39,7 @@ export default Component.extend(UppyUploadMixin, {
 
   didInsertElement() {
     this._super(...arguments);
-    this.composerInputEl = document.querySelector(".chat-composer-input");
+    this.composerInputEl = document.querySelector(".chat-composer__input");
     this.composerInputEl?.addEventListener("paste", this._pasteEventListener);
   },
 
@@ -77,19 +78,8 @@ export default Component.extend(UppyUploadMixin, {
   },
 
   _uploadDropTargetOptions() {
-    let targetEl;
-    if (this.chatStateManager.isFullPageActive) {
-      targetEl = document.querySelector(".full-page-chat");
-    } else {
-      targetEl = document.querySelector(".chat-drawer.is-expanded");
-    }
-
-    if (!targetEl) {
-      return this._super();
-    }
-
     return {
-      target: targetEl,
+      target: this.uploadDropZone || document.body,
     };
   },
 
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
index fe10dcbcec8..effa6885436 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
@@ -1,93 +1,105 @@
-{{#if this.composerService.replyToMsg}}
-  <ChatComposerMessageDetails
-    @message={{this.composerService.replyToMsg}}
-    @icon="reply"
-    @action={{action "cancelReplyTo"}}
-  />
-{{/if}}
-
-{{#if this.composerService.editingMessage}}
-  <ChatComposerMessageDetails
-    @message={{this.composerService.editingMessage}}
-    @icon="pencil-alt"
-    @action={{this.cancelEditing}}
-  />
-{{/if}}
-
-<div
-  role="region"
-  aria-label={{i18n "chat.aria_roles.composer"}}
-  class="chat-composer {{if this.disableComposer 'is-disabled'}}"
-  {{did-update this.updateEditingMessage this.composerService.editingMessage}}
->
-
-  <ChatComposerDropdown
-    @buttons={{this.dropdownButtons}}
-    @isDisabled={{this.disableComposer}}
-    @hasActivePanel={{and
-      this.chatEmojiPickerManager.picker
-      (eq this.chatEmojiPickerManager.picker.context @context)
-    }}
-    @onCloseActivePanel={{this.chatEmojiPickerManager.close}}
-  />
-
-  <DTextarea
-    @value={{readonly this.value}}
-    @input={{action "onTextareaInput" value="target.value"}}
-    @type="text"
-    @class={{concat-class
-      "chat-composer-input"
-      (concat "chat-composer-input--" @context)
-    }}
-    @disabled={{this.disableComposer}}
-    @autocorrect="on"
-    @autocapitalize="sentences"
-    @placeholder={{this.placeholder}}
-    @focus-in={{action "onTextareaFocusIn" value="target"}}
-    @rows={{1}}
-    data-chat-composer-context={{@context}}
-  />
-
-  {{#if this.isNetworkUnreliable}}
-    <span
-      class="chat-composer__unreliable-network"
-      title={{i18n "chat.unreliable_network"}}
-    >
-      {{d-icon "exclamation-circle"}}
-    </span>
+{{! template-lint-disable no-down-event-binding }}
+<div class="chat-composer__wrapper">
+  {{#if this.shouldRenderMessageDetails}}
+    <ChatComposerMessageDetails
+      @message={{if
+        this.currentMessage.editing
+        this.currentMessage
+        this.currentMessage.inReplyTo
+      }}
+      @icon="pencil-alt"
+      @cancelAction={{this.onCancel}}
+    />
   {{/if}}
 
-  <FlatButton
-    @action={{action "sendClicked"}}
-    @icon="paper-plane"
-    @class="icon-only send-btn chat-composer-inline-button"
-    @title={{this.sendTitle}}
-    @disabled={{this.sendDisabled}}
-  />
-
-  {{#unless this.disableComposer}}
-    <ChatComposerInlineButtons @buttons={{this.inlineButtons}} />
-  {{/unless}}
-</div>
-
-{{#if this.canAttachUploads}}
-  <ChatComposerUploads
-    @fileUploadElementId={{this.fileUploadElementId}}
-    @onUploadChanged={{this.uploadsChanged}}
-    @existingUploads={{or
-      this.chatChannel.draft.uploads
-      this.composerService.editingMessage.uploads
+  <div
+    role="region"
+    aria-label={{i18n "chat.aria_roles.composer"}}
+    class={{concat-class
+      "chat-composer"
+      (if this.isFocused "chat-composer--focused")
+      (if this.pane.sending "chat-composer--sending")
+      (if
+        this.sendEnabled
+        "chat-composer--send-enabled"
+        "chat-composer--send-disabled"
+      )
+      (if this.disabled "chat-composer--disabled")
     }}
-  />
-{{/if}}
+    {{did-update this.didUpdateMessage this.currentMessage}}
+    {{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}}
+    {{did-insert this.setupAppEvents}}
+    {{will-destroy this.teardownAppEvents}}
+  >
+    <div class="chat-composer__outer-container">
+      <div class="chat-composer__inner-container">
+        <ChatComposerDropdown
+          @buttons={{this.dropdownButtons}}
+          @isDisabled={{this.disabled}}
+          @hasActivePanel={{eq
+            this.chatEmojiPickerManager.picker.context
+            this.context
+          }}
+          @onCloseActivePanel={{this.chatEmojiPickerManager.close}}
+        />
 
-{{#unless this.chatChannel.isDraft}}
-  <div class="chat-replying-indicator-container">
-    <ChatReplyingIndicator @chatChannel={{this.chatChannel}} />
+        <div class="chat-composer__input-container">
+          <DTextarea
+            value={{readonly this.currentMessage.message}}
+            type="text"
+            class="chat-composer__input"
+            disabled={{this.disabled}}
+            autocorrect="on"
+            autocapitalize="sentences"
+            placeholder={{this.placeholder}}
+            rows={{1}}
+            {{did-insert this.setupTextareaInteractor}}
+            {{on "input" this.onInput}}
+            {{on "keydown" this.onKeyDown}}
+            {{on "focusin" this.onTextareaFocusIn}}
+            {{on "focusin" (fn this.computeIsFocused true)}}
+            {{on "focusout" (fn this.computeIsFocused false)}}
+            {{did-insert this.setupAutocomplete}}
+            data-chat-composer-context={{this.context}}
+          />
+        </div>
+
+        <DButton
+          @action={{this.onSend}}
+          @icon="paper-plane"
+          class="chat-composer__send-btn icon-only"
+          @title="chat.composer.send"
+          @disabled={{or this.disabled (not this.sendEnabled)}}
+          tabindex={{if this.sendEnabled 0 -1}}
+          {{on "focus" (fn this.computeIsFocused true)}}
+          {{on "blur" (fn this.computeIsFocused false)}}
+        />
+
+        {{#unless this.disabled}}
+          <ChatComposerInlineButtons @buttons={{this.inlineButtons}} />
+        {{/unless}}
+      </div>
+
+    </div>
   </div>
-{{/unless}}
 
-<ChatEmojiPicker
-  @context={{@context}}
-  @didSelectEmoji={{this.didSelectEmoji}}
-/>
\ No newline at end of file
+  {{#if this.canAttachUploads}}
+    <ChatComposerUploads
+      @fileUploadElementId={{this.fileUploadElementId}}
+      @onUploadChanged={{this.onUploadChanged}}
+      @existingUploads={{this.currentMessage.uploads}}
+      @uploadDropZone={{@uploadDropZone}}
+    />
+  {{/if}}
+
+  {{#if this.shouldRenderReplyingIndicator}}
+    <div class="chat-replying-indicator-container">
+      <ChatReplyingIndicator @chatChannel={{@channel}} />
+    </div>
+  {{/if}}
+
+  <ChatEmojiPicker
+    @context={{this.context}}
+    @didSelectEmoji={{this.onSelectEmoji}}
+  />
+</div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
index a3236f394c6..ead10d17c57 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
@@ -1,172 +1,268 @@
-import { isEmpty } from "@ember/utils";
-import Component from "@ember/component";
-import showModal from "discourse/lib/show-modal";
-import discourseComputed, {
-  afterRender,
-  bind,
-} from "discourse-common/utils/decorators";
-import I18n from "I18n";
-import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation";
-import userSearch from "discourse/lib/user-search";
+import Component from "@glimmer/component";
 import { action } from "@ember/object";
-import { cancel, next, schedule, throttle } from "@ember/runloop";
+import { inject as service } from "@ember/service";
+import { tracked } from "@glimmer/tracking";
+import { cancel, next } from "@ember/runloop";
 import { cloneJSON } from "discourse-common/lib/object";
+import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
+import showModal from "discourse/lib/show-modal";
+import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor";
+import { getOwner } from "discourse-common/lib/get-owner";
+import userSearch from "discourse/lib/user-search";
 import { findRawTemplate } from "discourse-common/lib/raw-templates";
 import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
 import { emojiUrlFor } from "discourse/lib/text";
-import { inject as service } from "@ember/service";
-import { reads } from "@ember/object/computed";
 import { SKIP } from "discourse/lib/autocomplete";
-import { Promise } from "rsvp";
+import I18n from "I18n";
 import { translations } from "pretty-text/emoji/data";
 import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
-import {
-  chatComposerButtons,
-  chatComposerButtonsDependentKeys,
-} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
+import { isEmpty, isPresent } from "@ember/utils";
 
-const THROTTLE_MS = 150;
+export default class ChatComposer extends Component {
+  @service capabilities;
+  @service site;
+  @service siteSettings;
+  @service chat;
+  @service chatComposerPresenceManager;
+  @service chatComposerWarningsTracker;
+  @service appEvents;
+  @service chatEmojiReactionStore;
+  @service chatEmojiPickerManager;
+  @service currentUser;
+  @service chatApi;
 
-export default Component.extend(TextareaTextManipulation, {
-  chatChannel: null,
-  chat: service(),
-  classNames: ["chat-composer-container"],
-  classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
-  chatEmojiReactionStore: service("chat-emoji-reaction-store"),
-  chatEmojiPickerManager: service("chat-emoji-picker-manager"),
-  chatStateManager: service("chat-state-manager"),
-  timer: null,
-  value: "",
-  inProgressUploads: null,
-  composerEventPrefix: "chat",
-  composerFocusSelector: ".chat-composer-input",
-  canAttachUploads: reads("siteSettings.chat_allow_uploads"),
-  isNetworkUnreliable: reads("chat.isNetworkUnreliable"),
-  typingMention: false,
-  chatComposerWarningsTracker: service(),
+  @tracked isFocused = false;
 
-  @discourseComputed(...chatComposerButtonsDependentKeys())
-  inlineButtons() {
+  get shouldRenderReplyingIndicator() {
+    return this.context === "channel" && !this.args.channel?.isDraft;
+  }
+
+  get shouldRenderMessageDetails() {
+    return this.currentMessage?.editing || this.currentMessage?.inReplyTo;
+  }
+
+  get inlineButtons() {
     return chatComposerButtons(this, "inline", this.context);
-  },
+  }
 
-  @discourseComputed(...chatComposerButtonsDependentKeys())
-  dropdownButtons() {
+  get dropdownButtons() {
     return chatComposerButtons(this, "dropdown", this.context);
-  },
+  }
 
-  @discourseComputed("chatEmojiPickerManager.{opened,context}")
-  emojiPickerVisible(picker) {
-    return picker.opened && picker.context === "chat-composer";
-  },
+  get fileUploadElementId() {
+    return this.context + "-file-uploader";
+  }
 
-  @discourseComputed("chatStateManager.isFullPageActive")
-  fileUploadElementId(fullPage) {
-    return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader";
-  },
-
-  init() {
-    this._super(...arguments);
-
-    this.appEvents.on(
-      "upload-mixin:chat-composer-uploader:in-progress-uploads",
-      this,
-      "_inProgressUploadsChanged"
+  get canAttachUploads() {
+    return (
+      this.siteSettings.chat_allow_uploads &&
+      isPresent(this.args.uploadDropZone)
     );
+  }
 
-    this.setProperties({
-      inProgressUploads: [],
-      _uploads: [],
-    });
+  get disabled() {
+    return (
+      (this.args.channel.isDraft &&
+        isEmpty(this.args.channel?.chatable?.users)) ||
+      !this.chat.userCanInteractWithChat ||
+      !this.args.channel.canModifyMessages(this.currentUser)
+    );
+  }
 
-    this.composerService?.registerFocusHandler(() => {
-      this._focusTextArea();
-    });
-  },
+  @action
+  persistDraft() {}
 
-  didInsertElement() {
-    this._super(...arguments);
+  @action
+  setupAutocomplete(textarea) {
+    const $textarea = $(textarea);
+    this.#applyUserAutocomplete($textarea);
+    this.#applyEmojiAutocomplete($textarea);
+    this.#applyCategoryHashtagAutocomplete($textarea);
+  }
 
-    this._textarea = this.element.querySelector(".chat-composer-input");
-    this._$textarea = $(this._textarea);
-    this._applyUserAutocomplete(this._$textarea);
-    this._applyCategoryHashtagAutocomplete(this._$textarea);
-    this._applyEmojiAutocomplete(this._$textarea);
-    this.appEvents.on("chat:insert-text", this, "insertText");
-    this._focusTextArea();
+  @action
+  setupTextareaInteractor(textarea) {
+    this.textareaInteractor = new TextareaInteractor(getOwner(this), textarea);
+  }
 
-    this.appEvents.on("chat:modify-selection", this, "_modifySelection");
+  @action
+  didUpdateMessage() {
+    cancel(this._persistHandler);
+    this.textareaInteractor.value = this.currentMessage.message || "";
+    this.textareaInteractor.focus({ refreshHeight: true });
+  }
+
+  @action
+  didUpdateInReplyTo() {
+    this.textareaInteractor.focus({ ensureAtEnd: true, refreshHeight: true });
+    this.persistDraft();
+  }
+
+  get currentMessage() {
+    return this.composer.message;
+  }
+
+  get hasContent() {
+    const minLength = this.siteSettings.chat_minimum_message_length || 0;
+    return (
+      this.currentMessage?.message?.length > minLength ||
+      (this.canAttachUploads && this.currentMessage?.uploads?.length > 0)
+    );
+  }
+
+  get sendEnabled() {
+    return this.hasContent && !this.pane.sending;
+  }
+
+  @action
+  setupAppEvents() {
+    this.appEvents.on("chat:modify-selection", this, "modifySelection");
     this.appEvents.on(
       "chat:open-insert-link-modal",
       this,
-      "_openInsertLinkModal"
+      "openInsertLinkModal"
     );
-    document.addEventListener("visibilitychange", this._blurInput);
-    document.addEventListener("resume", this._blurInput);
-    document.addEventListener("freeze", this._blurInput);
+  }
 
-    this.set("ready", true);
-  },
+  @action
+  teardownAppEvents() {
+    this.appEvents.off("chat:modify-selection", this, "modifySelection");
+    this.appEvents.off(
+      "chat:open-insert-link-modal",
+      this,
+      "openInsertLinkModal"
+    );
+  }
 
-  _modifySelection(opts = { type: null, context: null }) {
-    if (opts.context !== this.context) {
-      return;
-    }
-    const sel = this.getSelected("", { lineVal: true });
-    if (opts.type === "bold") {
-      this.applySurround(sel, "**", "**", "bold_text");
-    } else if (opts.type === "italic") {
-      this.applySurround(sel, "_", "_", "italic_text");
-    } else if (opts.type === "code") {
-      this.applySurround(sel, "`", "`", "code_text");
-    }
-  },
-
-  _openInsertLinkModal() {
-    const selected = this.getSelected("", { lineVal: true });
-    const linkText = selected?.value;
-    showModal("insert-hyperlink").setProperties({
-      linkText,
-      toolbarEvent: {
-        addText: (text) => this.addText(selected, text),
+  @action
+  insertDiscourseLocalDate() {
+    showModal("discourse-local-dates-create-modal").setProperties({
+      insertDate: (markup) => {
+        this.textareaInteractor.addText(
+          this.textareaInteractor.getSelected(),
+          markup
+        );
+        this.textareaInteractor.focus();
       },
     });
-  },
+  }
 
-  willDestroyElement() {
-    this._super(...arguments);
+  @action
+  uploadClicked() {
+    document.querySelector(`#${this.fileUploadElementId}`).click();
+  }
 
-    this.appEvents.off(
-      "upload-mixin:chat-composer-uploader:in-progress-uploads",
-      this,
-      "_inProgressUploadsChanged"
-    );
+  @action
+  computeIsFocused(isFocused) {
+    next(() => {
+      this.isFocused = isFocused;
+    });
+  }
 
-    cancel(this.timer);
+  @action
+  onInput(event) {
+    this.currentMessage.message = event.target.value;
+    this.textareaInteractor.refreshHeight();
+    this.reportReplyingPresence();
+    this.persistDraft();
+    this.captureMentions();
+  }
 
-    this.appEvents.off("chat:insert-text", this, "insertText");
-    this.appEvents.off("chat:modify-selection", this, "_modifySelection");
-    this.appEvents.off(
-      "chat:open-insert-link-modal",
-      this,
-      "_openInsertLinkModal"
-    );
-    document.removeEventListener("visibilitychange", this._blurInput);
-    document.removeEventListener("resume", this._blurInput);
-    document.removeEventListener("freeze", this._blurInput);
-  },
+  @action
+  onUploadChanged(uploads, { inProgressUploadsCount }) {
+    if (
+      typeof uploads !== "undefined" &&
+      inProgressUploadsCount !== "undefined" &&
+      inProgressUploadsCount === 0 &&
+      this.currentMessage
+    ) {
+      this.currentMessage.uploads = cloneJSON(uploads);
+    }
 
-  // It is important that this is keyDown and not keyUp, otherwise
-  // we add new lines to chat message on send and on edit, because
-  // you cannot prevent default with a keyUp event -- it is like trying
-  // to shut the gate after the horse has already bolted!
-  keyDown(event) {
-    if (this.site.mobileView || event.altKey || event.metaKey) {
+    this.textareaInteractor.focus();
+    this.reportReplyingPresence();
+    this.persistDraft();
+  }
+
+  @action
+  onSend() {
+    if (!this.sendEnabled) {
       return;
     }
 
-    // keyCode for 'Enter'
-    if (event.keyCode === 13) {
+    if (this.site.mobileView) {
+      // prevents to hide the keyboard after sending a message
+      // we use direct DOM manipulation here because textareaInteractor.focus()
+      // is using the runloop which is too late
+      this.textareaInteractor.textarea.focus();
+    }
+
+    this.args.onSendMessage(this.currentMessage);
+    this.textareaInteractor.focus({ refreshHeight: true });
+  }
+
+  @action
+  onCancel() {
+    this.composer.cancel();
+  }
+
+  reportReplyingPresence() {
+    if (this.args.channel.isDraft) {
+      return;
+    }
+
+    this.chatComposerPresenceManager.notifyState(
+      this.args.channel.id,
+      !this.currentMessage.editing && this.hasContent
+    );
+  }
+
+  @action
+  modifySelection(event, options = { type: null, context: null }) {
+    if (options.context !== this.context) {
+      return;
+    }
+
+    const sel = this.textareaInteractor.getSelected("", { lineVal: true });
+    if (options.type === "bold") {
+      this.textareaInteractor.applySurround(sel, "**", "**", "bold_text");
+    } else if (options.type === "italic") {
+      this.textareaInteractor.applySurround(sel, "_", "_", "italic_text");
+    } else if (options.type === "code") {
+      this.textareaInteractor.applySurround(sel, "`", "`", "code_text");
+    }
+  }
+
+  @action
+  onTextareaFocusIn(textarea) {
+    if (!this.capabilities.isIOS) {
+      return;
+    }
+
+    // hack to prevent the whole viewport
+    // to move on focus input
+    textarea = document.querySelector(".chat-composer__input");
+    textarea.style.transform = "translateY(-99999px)";
+    textarea.focus();
+    window.requestAnimationFrame(() => {
+      window.requestAnimationFrame(() => {
+        textarea.style.transform = "";
+      });
+    });
+  }
+
+  @action
+  onKeyDown(event) {
+    if (
+      this.site.mobileView ||
+      event.altKey ||
+      event.metaKey ||
+      this.#isAutocompleteDisplayed()
+    ) {
+      return;
+    }
+
+    if (event.key === "Enter") {
       if (event.shiftKey) {
         // Shift+Enter: insert newline
         return;
@@ -175,224 +271,139 @@ export default Component.extend(TextareaTextManipulation, {
       // Ctrl+Enter, plain Enter: send
       if (!event.ctrlKey) {
         // if we are inside a code block just insert newline
-        const { pre } = this.getSelected(null, { lineVal: true });
-        if (this.isInside(pre, /(^|\n)```/g)) {
+        const { pre } = this.textareaInteractor.getSelected({ lineVal: true });
+        if (this.textareaInteractor.isInside(pre, /(^|\n)```/g)) {
           return;
         }
       }
 
-      this.sendClicked();
+      this.onSend();
+      event.preventDefault();
       return false;
     }
 
     if (
       event.key === "ArrowUp" &&
-      this._messageIsEmpty() &&
-      !this.composerService?.editingMessage
+      !this.hasContent &&
+      !this.currentMessage.editing
     ) {
-      event.preventDefault();
-      this.paneService?.editLastMessageRequested();
+      const editableMessage = this.pane?.lastCurrentUserMessage;
+      if (editableMessage) {
+        this.composer.editMessage(editableMessage);
+      }
     }
 
     if (event.key === "Escape") {
-      if (this.composerService?.replyToMsg) {
-        this.set("value", "");
-        this.composerService?.setReplyTo(null);
+      if (this.currentMessage?.inReplyTo) {
+        this.reset();
         return false;
-      } else if (this.composerService?.editingMessage) {
-        this.cancelEditing();
+      } else if (this.currentMessage?.editing) {
+        this.composer.onCancelEditing();
         return false;
       } else {
-        this._textarea.blur();
+        event.target.blur();
       }
     }
-  },
-
-  didReceiveAttrs() {
-    this._super(...arguments);
-
-    if (
-      !this.composerService?.editingMessage &&
-      this.chatChannel?.draft &&
-      this.chatChannel?.canModifyMessages(this.currentUser)
-    ) {
-      // uses uploads from draft here...
-      this.set("value", this.chatChannel.draft.message);
-      this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg);
-
-      this._captureMentions();
-      this._syncUploads(this.chatChannel.draft.uploads);
-    }
-
-    this.resizeTextarea();
-  },
+  }
 
   @action
-  updateEditingMessage() {
-    if (
-      this.composerService?.editingMessage &&
-      !this.paneService?.sendingLoading
-    ) {
-      this.set("value", this.composerService?.editingMessage.message);
+  reset() {
+    this.composer.reset(this.args.channel);
+  }
 
-      this.composerService?.setReplyTo(null);
-
-      this._syncUploads(this.composerService?.editingMessage.uploads);
-      this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
-    }
-  },
-
-  // the chat-composer needs to be able to set the internal list of uploads
-  // for chat-composer-uploads to preload in existing uploads for drafts
-  // and for when messages are being edited.
-  //
-  // the opposite is true as well -- when an upload is completed the chat-composer
-  // needs its internal state updated so drafts can be saved, which is handled
-  // by the uploadsChanged action
-  _syncUploads(newUploads = []) {
-    const currentUploadIds = this._uploads.mapBy("id");
-    const newUploadIds = newUploads.mapBy("id");
-
-    // don't need to load the uploads into chat-composer-uploads if
-    // nothing has changed otherwise we would rerender for no reason
-    if (
-      currentUploadIds.length === newUploadIds.length &&
-      newUploadIds.every((newUploadId) =>
-        currentUploadIds.includes(newUploadId)
-      )
-    ) {
+  @action
+  openInsertLinkModal(event, options = { context: null }) {
+    if (options.context !== this.context) {
       return;
     }
 
-    this.set("_uploads", cloneJSON(newUploads));
-  },
-
-  _inProgressUploadsChanged(inProgressUploads) {
-    next(() => {
-      if (this.isDestroying || this.isDestroyed) {
-        return;
-      }
-
-      this.set("inProgressUploads", inProgressUploads);
+    const selected = this.textareaInteractor.getSelected("", { lineVal: true });
+    const linkText = selected?.value;
+    showModal("insert-hyperlink").setProperties({
+      linkText,
+      toolbarEvent: {
+        addText: (text) => this.textareaInteractor.addText(selected, text),
+      },
     });
-  },
+  }
 
   @action
-  onTextareaInput(value) {
-    this.set("value", value);
-    this.resizeTextarea();
-
-    this._captureMentions();
-
-    // throttle, not debounce, because we do eventually want to react during the typing
-    this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS);
-  },
-
-  @bind
-  _handleTextareaInput() {
-    this.composerService?.onComposerValueChange?.({ value: this.value });
-  },
-
-  @bind
-  _captureMentions() {
-    if (this.value) {
-      this.chatComposerWarningsTracker.trackMentions(this.value);
-    }
-  },
-
-  @bind
-  _blurInput() {
-    document.activeElement?.blur();
-  },
-
-  @action
-  uploadClicked() {
-    this.element.querySelector(`#${this.fileUploadElementId}`).click();
-  },
-
-  @bind
-  didSelectEmoji(emoji) {
+  onSelectEmoji(emoji) {
     const code = `:${emoji}:`;
     this.chatEmojiReactionStore.track(code);
-    this.addText(this.getSelected(), code);
+    this.textareaInteractor.addText(
+      this.textareaInteractor.getSelected(),
+      code
+    );
 
     if (this.site.desktopView) {
-      this._focusTextArea();
+      this.textareaInteractor.focus();
     } else {
       this.chatEmojiPickerManager.close();
     }
-  },
+  }
 
   @action
-  closeComposerDropdown() {
-    this.chatEmojiPickerManager.close();
-    this.appEvents.trigger("d-popover:close");
-  },
+  captureMentions() {
+    if (this.hasContent) {
+      this.chatComposerWarningsTracker.trackMentions(
+        this.currentMessage.message
+      );
+    }
+  }
 
-  @action
-  insertDiscourseLocalDate() {
-    showModal("discourse-local-dates-create-modal").setProperties({
-      insertDate: (markup) => {
-        this.addText(this.getSelected(), markup);
+  #applyUserAutocomplete($textarea) {
+    if (!this.siteSettings.enable_mentions) {
+      return;
+    }
+
+    $textarea.autocomplete({
+      template: findRawTemplate("user-selector-autocomplete"),
+      key: "@",
+      width: "100%",
+      treatAsTextarea: true,
+      autoSelectFirstSuggestion: true,
+      transformComplete: (v) => v.username || v.name,
+      dataSource: (term) => {
+        return userSearch({ term, includeGroups: true }).then((result) => {
+          if (result?.users?.length > 0) {
+            const presentUserNames =
+              this.chat.presenceChannel.users?.mapBy("username");
+            result.users.forEach((user) => {
+              if (presentUserNames.includes(user.username)) {
+                user.cssClasses = "is-online";
+              }
+            });
+          }
+          return result;
+        });
+      },
+      afterComplete: (text, event) => {
+        event.preventDefault();
+        this.textareaInteractor.value = text;
+        this.textareaInteractor.focus();
+        this.captureMentions();
       },
     });
-  },
+  }
 
-  // text-area-manipulation mixin override
-  addText() {
-    this._super(...arguments);
-
-    this.resizeTextarea();
-  },
-
-  _applyUserAutocomplete($textarea) {
-    if (this.siteSettings.enable_mentions) {
-      $textarea.autocomplete({
-        template: findRawTemplate("user-selector-autocomplete"),
-        key: "@",
-        width: "100%",
-        treatAsTextarea: true,
-        autoSelectFirstSuggestion: true,
-        transformComplete: (v) => v.username || v.name,
-        dataSource: (term) => {
-          return userSearch({ term, includeGroups: true }).then((result) => {
-            if (result?.users?.length > 0) {
-              const presentUserNames =
-                this.chat.presenceChannel.users?.mapBy("username");
-              result.users.forEach((user) => {
-                if (presentUserNames.includes(user.username)) {
-                  user.cssClasses = "is-online";
-                }
-              });
-            }
-            return result;
-          });
-        },
-        afterComplete: (text) => {
-          this.set("value", text);
-          this._focusTextArea();
-          this._captureMentions();
-        },
-      });
-    }
-  },
-
-  _applyCategoryHashtagAutocomplete($textarea) {
+  #applyCategoryHashtagAutocomplete($textarea) {
     setupHashtagAutocomplete(
       this.site.hashtag_configurations["chat-composer"],
       $textarea,
       this.siteSettings,
       {
         treatAsTextarea: true,
-        afterComplete: (value) => {
-          this.set("value", value);
-          return this._focusTextArea();
+        afterComplete: (text, event) => {
+          event.preventDefault();
+          this.textareaInteractor.value = text;
+          this.textareaInteractor.focus();
         },
       }
     );
-  },
+  }
 
-  _applyEmojiAutocomplete($textarea) {
+  #applyEmojiAutocomplete($textarea) {
     if (!this.siteSettings.enable_emoji) {
       return;
     }
@@ -400,12 +411,12 @@ export default Component.extend(TextareaTextManipulation, {
     $textarea.autocomplete({
       template: findRawTemplate("emoji-selector-autocomplete"),
       key: ":",
-      afterComplete: (text) => {
-        this.set("value", text);
-        this._focusTextArea();
+      afterComplete: (text, event) => {
+        event.preventDefault();
+        this.textareaInteractor.value = text;
+        this.textareaInteractor.focus();
       },
       treatAsTextarea: true,
-
       onKeyUp: (text, cp) => {
         const matches =
           /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
@@ -416,7 +427,6 @@ export default Component.extend(TextareaTextManipulation, {
           return [matches[1]];
         }
       },
-
       transformComplete: (v) => {
         if (v.code) {
           this.chatEmojiReactionStore.track(v.code);
@@ -430,7 +440,6 @@ export default Component.extend(TextareaTextManipulation, {
           return "";
         }
       },
-
       dataSource: (term) => {
         return new Promise((resolve) => {
           const full = `:${term}`;
@@ -472,8 +481,7 @@ export default Component.extend(TextareaTextManipulation, {
 
           // note this will only work for emojis starting with :
           // eg: :-)
-          const emojiTranslation =
-            this.get("site.custom_emoji_translation") || {};
+          const emojiTranslation = this.site.custom_emoji_translation || {};
           const allTranslations = Object.assign(
             {},
             translations,
@@ -483,7 +491,7 @@ export default Component.extend(TextareaTextManipulation, {
             return resolve([allTranslations[full]]);
           }
 
-          const emojiDenied = this.get("site.denied_emojis") || [];
+          const emojiDenied = this.site.denied_emojis || [];
           const match = term.match(/^:?(.*?):t([2-6])?$/);
           if (match) {
             const name = match[1];
@@ -520,264 +528,9 @@ export default Component.extend(TextareaTextManipulation, {
           });
       },
     });
-  },
+  }
 
-  @afterRender
-  _focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) {
-    if (this.chatChannel.isDraft) {
-      return;
-    }
-
-    if (!this._textarea) {
-      return;
-    }
-
-    if (opts.resizeTextarea) {
-      this.resizeTextarea();
-    }
-
-    if (opts.ensureAtEnd) {
-      this._textarea.setSelectionRange(this.value.length, this.value.length);
-    }
-
-    if (this.capabilities.isIpadOS || this.site.mobileView) {
-      return;
-    }
-
-    schedule("afterRender", () => {
-      this._textarea?.focus();
-    });
-  },
-
-  @action
-  onEmojiSelected(code) {
-    this.emojiSelected(code);
-    this.set("emojiPickerIsActive", false);
-  },
-
-  @discourseComputed(
-    "chatChannel.{id,chatable.users.[]}",
-    "chat.userCanInteractWithChat"
-  )
-  disableComposer(channel, userCanInteractWithChat) {
-    return (
-      (channel.isDraft && isEmpty(channel?.chatable?.users)) ||
-      !userCanInteractWithChat ||
-      !channel.canModifyMessages(this.currentUser)
-    );
-  },
-
-  @discourseComputed(
-    "chatChannel.{chatable.users.[],id}",
-    "chat.userCanInteractWithChat"
-  )
-  placeholder(chatChannel, userCanInteractWithChat) {
-    if (!chatChannel.canModifyMessages(this.currentUser)) {
-      return I18n.t(
-        `chat.placeholder_new_message_disallowed.${chatChannel.status}`
-      );
-    }
-
-    if (chatChannel.isDraft) {
-      if (chatChannel?.chatable?.users?.length) {
-        return I18n.t("chat.placeholder_start_conversation_users", {
-          commaSeparatedUsernames: chatChannel.chatable.users
-            .mapBy("username")
-            .join(I18n.t("word_connector.comma")),
-        });
-      } else {
-        return I18n.t("chat.placeholder_start_conversation");
-      }
-    }
-
-    if (!userCanInteractWithChat) {
-      return I18n.t("chat.placeholder_silenced");
-    } else {
-      return this.messageRecipient(chatChannel);
-    }
-  },
-
-  messageRecipient(chatChannel) {
-    if (chatChannel.isDirectMessageChannel) {
-      const directMessageRecipients = chatChannel.chatable.users;
-      if (
-        directMessageRecipients.length === 1 &&
-        directMessageRecipients[0].id === this.currentUser.id
-      ) {
-        return I18n.t("chat.placeholder_self");
-      }
-
-      return I18n.t("chat.placeholder_users", {
-        commaSeparatedNames: directMessageRecipients
-          .map((u) => u.name || `@${u.username}`)
-          .join(I18n.t("word_connector.comma")),
-      });
-    } else {
-      return I18n.t("chat.placeholder_channel", {
-        channelName: `#${chatChannel.title}`,
-      });
-    }
-  },
-
-  @discourseComputed(
-    "value",
-    "paneService.sendingLoading",
-    "disableComposer",
-    "inProgressUploads.[]"
-  )
-  sendDisabled(value, loading, disableComposer, inProgressUploads) {
-    if (loading || disableComposer || inProgressUploads.length > 0) {
-      return true;
-    }
-
-    return !this._messageIsValid();
-  },
-
-  @action
-  sendClicked() {
-    if (this.site.mobileView) {
-      // prevents android to hide the keyboard after sending a message
-      // we do a focusTextarea later but it's too late for android
-      document.querySelector(this.composerFocusSelector).focus();
-    }
-
-    if (this.sendDisabled) {
-      return;
-    }
-
-    this.composerService?.editingMessage
-      ? this.internalEditMessage()
-      : this.internalSendMessage();
-  },
-
-  @action
-  internalSendMessage() {
-    // FIXME: This is fairly hacky, we should have a nicer
-    // flow and relationship between the panes for resetting
-    // the value here on send.
-    const _previousValue = this.value;
-    this.set("value", "");
-    return this.sendMessage(_previousValue, this._uploads)
-      .then(this.reset)
-      .catch(() => {
-        this.set("value", _previousValue);
-      });
-  },
-
-  @action
-  internalEditMessage() {
-    return this.paneService
-      ?.editMessage(this.value, this._uploads)
-      .then(this.reset);
-  },
-
-  _messageIsValid() {
-    const validLength =
-      (this.value || "").trim().length >=
-      (this.siteSettings.chat_minimum_message_length || 0);
-
-    if (this.canAttachUploads) {
-      if (this._messageIsEmpty()) {
-        // If message is empty, an an upload must present for sending to be enabled
-        return this._uploads.length;
-      } else {
-        // Message is non-empty. Make sure it's long enough to be valid.
-        return validLength;
-      }
-    }
-
-    // Attachments are disabled so for a message to be valid it must be long enough.
-    return validLength;
-  },
-
-  _messageIsEmpty() {
-    return (this.value || "").trim() === "";
-  },
-
-  @action
-  reset() {
-    if (this.isDestroyed || this.isDestroying) {
-      return;
-    }
-
-    this.setProperties({
-      value: "",
-      inReplyMsg: null,
-    });
-    this._captureMentions();
-    this._syncUploads([]);
-    this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
-    this.composerService?.onComposerValueChange?.(
-      this.value,
-      this._uploads,
-      this.composerService?.replyToMsg
-    );
-  },
-
-  @action
-  cancelReplyTo() {
-    this.composerService?.setReplyTo(null);
-  },
-
-  @action
-  cancelEditing() {
-    this.composerService?.cancelEditing();
-    this.set("value", "");
-    this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
-  },
-
-  _cursorIsOnEmptyLine() {
-    const selectionStart = this._textarea.selectionStart;
-    if (selectionStart === 0) {
-      return true;
-    } else if (this._textarea.value.charAt(selectionStart - 1) === "\n") {
-      return true;
-    } else {
-      return false;
-    }
-  },
-
-  @action
-  uploadsChanged(uploads, { inProgressUploadsCount }) {
-    this.set("_uploads", cloneJSON(uploads));
-    this.composerService?.onComposerValueChange?.({
-      uploads: this._uploads,
-      inProgressUploadsCount,
-    });
-  },
-
-  @action
-  onTextareaFocusIn(target) {
-    if (!this.capabilities.isIOS) {
-      return;
-    }
-
-    // hack to prevent the whole viewport
-    // to move on focus input
-    target = document.querySelector(".chat-composer-input");
-    target.style.transform = "translateY(-99999px)";
-    target.focus();
-    window.requestAnimationFrame(() => {
-      window.requestAnimationFrame(() => {
-        target.style.transform = "";
-      });
-    });
-  },
-
-  @action
-  resizeTextarea() {
-    schedule("afterRender", () => {
-      if (!this._textarea) {
-        return;
-      }
-
-      // this is a quirk which forces us to `auto` first or textarea
-      // won't resize
-      this._textarea.style.height = "auto";
-
-      // +1 is to workaround a rounding error visible on electron
-      // causing scrollbars to show when they shouldn’t
-      this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
-    });
-  },
-});
+  #isAutocompleteDisplayed() {
+    return document.querySelector(".autocomplete");
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs
index fccac8b872a..35d7499f23b 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs
@@ -19,6 +19,6 @@
   />
 
   {{#if this.previewedChannel}}
-    <ChatLivePane @channel={{this.previewedChannel}} @includeHeader={{false}} />
+    <ChatChannel @channel={{this.previewedChannel}} @includeHeader={{false}} />
   {{/if}}
 </div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs
index 7a94b39267e..f78588d7632 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs
@@ -16,7 +16,7 @@
     {{did-update this.fetchChannel @params.channelId}}
   >
     {{#if this.chat.activeChannel}}
-      <ChatLivePane
+      <ChatChannel
         @targetMessageId={{readonly @params.messageId}}
         @channel={{this.chat.activeChannel}}
       />
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
index d4eafdc3ceb..460fae13aa1 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
@@ -210,7 +210,7 @@ export default class ChatMessage extends Component {
     }
 
     document.activeElement.blur();
-    document.querySelector(".chat-composer-input")?.blur();
+    document.querySelector(".chat-composer__input")?.blur();
 
     this._setActiveMessage();
   }
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs
new file mode 100644
index 00000000000..a717e52b509
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs
@@ -0,0 +1 @@
+<div class="chat-side-panel-resizer"></div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs
index a177d7d0c7e..fc0c040248f 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs
@@ -1,5 +1,18 @@
 {{#if this.chatStateManager.isSidePanelExpanded}}
-  <div class="chat-side-panel">
+  <div
+    class="chat-side-panel"
+    {{did-insert this.setSidePanel}}
+    {{chat/resizable-node
+      ".chat-side-panel-resizer"
+      this.didResize
+      (hash position=false vertical=false mutate=false)
+    }}
+    style={{if
+      (and this.site.desktopView this.chatStateManager.isFullPageActive)
+      this.width
+    }}
+  >
     {{yield}}
+    <ChatSidePanelResizer />
   </div>
 {{/if}}
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js
index 53be6e18eb9..5c046ab32d2 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js
@@ -1,6 +1,49 @@
 import Component from "@glimmer/component";
 import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import { htmlSafe } from "@ember/template";
+import { tracked } from "@glimmer/tracking";
+
+const MIN_CHAT_CHANNEL_WIDTH = 300;
 
 export default class ChatSidePanel extends Component {
   @service chatStateManager;
+  @service chatSidePanelSize;
+  @service site;
+
+  @tracked sidePanel;
+
+  @action
+  setSidePanel(element) {
+    this.sidePanel = element;
+  }
+
+  get width() {
+    if (!this.sidePanel) {
+      return;
+    }
+
+    const maxWidth = Math.min(
+      this.#maxWidth(this.sidePanel),
+      this.chatSidePanelSize.width
+    );
+
+    return htmlSafe(`width:${maxWidth}px`);
+  }
+
+  @action
+  didResize(element, size) {
+    const parentWidth = element.parentElement.getBoundingClientRect().width;
+    const mainPanelWidth = parentWidth - size.width;
+
+    if (mainPanelWidth > MIN_CHAT_CHANNEL_WIDTH) {
+      this.chatSidePanelSize.width = size.width;
+      element.style.width = size.width + "px";
+    }
+  }
+
+  #maxWidth(element) {
+    const parentWidth = element.parentElement.getBoundingClientRect().width;
+    return parentWidth - MIN_CHAT_CHANNEL_WIDTH;
+  }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
index 1c00da944af..43f265938ad 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
@@ -1,10 +1,12 @@
 <div
   class={{concat-class "chat-thread" (if this.loading "loading")}}
   data-id={{this.thread.id}}
+  {{did-insert this.setUploadDropZone}}
   {{did-insert this.subscribeToUpdates}}
   {{did-insert this.loadMessages}}
   {{did-update this.subscribeToUpdates this.thread.id}}
   {{did-update this.loadMessages this.thread.id}}
+  {{did-insert this.setupMessage}}
   {{will-destroy this.unsubscribeFromUpdates}}
 >
   {{#if @includeHeader}}
@@ -13,7 +15,8 @@
       <LinkTo
         class="chat-thread__close btn-flat btn btn-icon no-text"
         @route="chat.channel"
-        @models={{this.chat.activeChannel.routeModels}}
+        @models={{this.channel.routeModels}}
+        title={{i18n "chat.thread.close"}}
       >
         {{d-icon "times"}}
       </LinkTo>
@@ -47,20 +50,20 @@
   {{#if this.chatChannelThreadPane.selectingMessages}}
     <ChatSelectionManager
       @selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}}
-      @chatChannel={{this.chat.activeChannel}}
+      @chatChannel={{this.channel}}
       @cancelSelecting={{action
         this.chatChannelThreadPane.cancelSelecting
-        this.chat.activeChannel.selectedMessages
+        this.channel.selectedMessages
       }}
       @context="thread"
     />
   {{else}}
-    <ChatComposer
-      @sendMessage={{this.sendMessage}}
-      @chatChannel={{this.channel}}
-      @composerService={{this.chatChannelThreadComposer}}
-      @paneService={{this.chatChannelThreadPane}}
-      @context="thread"
+    <Chat::Composer::Thread
+      @channel={{this.channel}}
+      @onSendMessage={{this.onSendMessage}}
+      @uploadDropZone={{this.uploadDropZone}}
     />
   {{/if}}
+
+  <ChatUploadDropZone @model={{this.thread}} />
 </div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
index ee63ef2ed39..7eb9fc2851f 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
@@ -1,6 +1,4 @@
 import Component from "@glimmer/component";
-import { cloneJSON } from "discourse-common/lib/object";
-import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
 import { tracked } from "@glimmer/tracking";
 import { action } from "@ember/object";
 import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
@@ -27,6 +25,7 @@ export default class ChatThreadPanel extends Component {
 
   @tracked loading;
   @tracked loadingMorePast;
+  @tracked uploadDropZone;
 
   scrollable = null;
 
@@ -43,6 +42,19 @@ export default class ChatThreadPanel extends Component {
     this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
   }
 
+  @action
+  setUploadDropZone(element) {
+    this.uploadDropZone = element;
+  }
+
+  @action
+  setupMessage() {
+    this.chatChannelThreadComposer.message = ChatMessage.createDraftMessage(
+      this.channel,
+      { user: this.currentUser, thread_id: this.thread.id }
+    );
+  }
+
   @action
   unsubscribeFromUpdates() {
     this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
@@ -168,27 +180,34 @@ export default class ChatThreadPanel extends Component {
   }
 
   @action
-  sendMessage(message, uploads = []) {
+  onSendMessage(message) {
+    if (message.editing) {
+      this.#sendEditMessage(message);
+    } else {
+      this.#sendNewMessage(message);
+    }
+  }
+
+  @action
+  resetComposer() {
+    this.chatChannelThreadComposer.reset(this.channel);
+  }
+
+  #sendNewMessage(message) {
     // TODO (martin) For desktop notifications
     // resetIdle()
-    if (this.chatChannelThreadPane.sendingLoading) {
+    if (this.chatChannelThreadPane.sending) {
       return;
     }
 
-    this.chatChannelThreadPane.sendingLoading = true;
-    this.channel.draft = ChatMessageDraft.create();
+    this.chatChannelThreadPane.sending = true;
 
     // TODO (martin) Handling case when channel is not followed???? IDK if we
     // even let people send messages in threads without this, seems weird.
 
-    const stagedMessage = ChatMessage.createStagedMessage(this.channel, {
-      message,
-      created_at: new Date(),
-      uploads: cloneJSON(uploads),
-      user: this.currentUser,
-      thread_id: this.thread.id,
-    });
-
+    this.thread.stageMessage(message);
+    const stagedMessage = message;
+    this.resetComposer();
     this.thread.messagesManager.addMessages([stagedMessage]);
 
     // TODO (martin) Scrolling!!
@@ -214,8 +233,25 @@ export default class ChatThreadPanel extends Component {
         if (this._selfDeleted) {
           return;
         }
-        this.chatChannelThreadPane.sendingLoading = false;
-        this.chatChannelThreadPane.resetAfterSend();
+        this.chatChannelThreadPane.sending = false;
+      });
+  }
+
+  #sendEditMessage(message) {
+    this.chatChannelThreadPane.sending = true;
+
+    const data = {
+      new_message: message.message,
+      upload_ids: message.uploads.map((upload) => upload.id),
+    };
+
+    this.resetComposer();
+
+    return this.chatApi
+      .editMessage(message.channelId, message.id, data)
+      .catch(popupAjaxError)
+      .finally(() => {
+        this.chatChannelThreadPane.sending = false;
       });
   }
 
@@ -302,6 +338,6 @@ export default class ChatThreadPanel extends Component {
       }
     }
 
-    this.chatChannelThreadPane.resetAfterSend();
+    this.resetComposer();
   }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs
new file mode 100644
index 00000000000..08980dba7aa
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs
@@ -0,0 +1,65 @@
+<div class="chat-upload-drop-zone">
+  <div class="chat-upload-drop-zone__content">
+    <div class="chat-upload-drop-zone__background">
+      <svg
+        width="94"
+        height="90"
+        viewBox="0 0 94 90"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M94 40.5591C94 69.8685 64.0686 90 40.9592 90C17.8499 90 0 83.9085 0 60.6907C0 37.4729 28.458 0 51.5674 0C74.6768 0 94 17.3413 94 40.5591Z"
+          fill="#D1F0FF"
+        ></path>
+      </svg>
+    </div>
+    <div class="chat-upload-drop-zone__illustration">
+      <svg
+        width="106"
+        height="84"
+        viewBox="0 0 106 84"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <rect
+          x="55.832"
+          y="6.82959"
+          width="45.7854"
+          height="33.8408"
+          transform="rotate(13.4039 55.832 6.82959)"
+          fill="#D9D9D9"
+        ></rect>
+        <path
+          d="M100.66 13.7645L61.1414 4.34705C58.6715 3.75846 56.1786 5.37298 55.59 7.84288L47.6214 41.2815C47.0102 43.8464 48.5297 46.3167 50.9996 46.9053L90.518 56.3227C92.9879 56.9113 95.5532 55.4145 96.1644 52.8496L104.133 19.4109C104.722 16.941 103.225 14.3757 100.66 13.7645ZM58.7093 41.5144C58.5961 41.9894 58.1482 42.1838 57.7682 42.0933L53.2084 41.0067C52.7334 40.8935 52.5163 40.5406 52.6295 40.0656L53.7161 35.5058C53.8067 35.1258 54.1822 34.8137 54.6572 34.9269L59.122 35.9909C59.502 36.0815 59.7915 36.552 59.7009 36.932L58.7093 41.5144ZM61.6069 29.3549C61.4938 29.8299 61.0459 30.0243 60.6659 29.9338L56.1061 28.8472C55.6311 28.734 55.414 28.3811 55.5272 27.9061L56.6138 23.3463C56.7044 22.9663 57.0799 22.6542 57.5549 22.7674L62.0197 23.8314C62.3997 23.922 62.6891 24.3925 62.5986 24.7725L61.5119 29.3323L61.6069 29.3549ZM64.5046 17.1954C64.3914 17.6704 63.9435 17.8648 63.5635 17.7743L59.0037 16.6877C58.5287 16.5745 58.3117 16.2216 58.4249 15.7466L59.5115 11.1868C59.602 10.8068 59.9776 10.4947 60.4526 10.6079L64.9174 11.6719C65.2974 11.7625 65.5868 12.233 65.4962 12.613L64.5046 17.1954ZM81.6894 46.1876C81.4857 47.0426 80.5673 47.5264 79.8073 47.3453L64.6079 43.7232C63.753 43.5195 63.2464 42.6961 63.4502 41.8411L65.6234 32.7215C65.8045 31.9615 66.6506 31.36 67.5056 31.5637L82.705 35.1858C83.4649 35.3669 84.0438 36.308 83.8627 37.068L81.6894 46.1876ZM86.036 27.9483C85.8322 28.8033 84.9138 29.2872 84.1538 29.1061L68.9544 25.484C68.0995 25.2802 67.593 24.4568 67.7967 23.6018L69.97 14.4822C70.1511 13.7222 70.9971 13.1207 71.8521 13.3245L87.0515 16.9466C87.8114 17.1277 88.3903 18.0687 88.2092 18.8287L86.036 27.9483ZM92.1479 49.483C92.0347 49.958 91.5868 50.1524 91.2068 50.0619L86.742 48.9979C86.267 48.8847 86.05 48.5318 86.1631 48.0568L87.2498 43.497C87.3403 43.117 87.8109 42.8276 88.1908 42.9181L92.7507 44.0048C93.1306 44.0953 93.4201 44.5659 93.3295 44.9458L92.2429 49.5057L92.1479 49.483ZM95.0456 37.3235C94.9324 37.7985 94.4845 37.9929 94.1045 37.9024L89.6397 36.8384C89.1647 36.7252 88.9476 36.3723 89.0608 35.8973L90.1474 31.3375C90.238 30.9575 90.6135 30.6455 91.0885 30.7586L95.5533 31.8226C95.9333 31.9132 96.2228 32.3837 96.1322 32.7637L95.0456 37.3235ZM97.9432 25.164C97.8301 25.639 97.3822 25.8334 97.0022 25.7429L92.5374 24.6789C92.0624 24.5657 91.8453 24.2128 91.9585 23.7378L93.0451 19.178C93.1357 18.798 93.5112 18.486 93.8912 18.5765L98.356 19.6405C98.736 19.731 99.0254 20.2016 98.9349 20.5816L97.8483 25.1414L97.9432 25.164Z"
+          fill="#AFAFAF"
+        ></path>
+        <path
+          d="M30.7898 24.814L27.2823 9.2672L4.41944 14.4252C2.81904 14.7863 1.95958 16.3017 2.29486 17.7878L14.2615 70.8296C14.6226 72.43 16.0236 73.3153 17.624 72.9542L56.0337 64.2887C57.5198 63.9534 58.5193 62.5266 58.1582 60.9262L49.699 23.4311L34.1523 26.9385C32.5519 27.2996 31.1508 26.4144 30.7898 24.814ZM48.719 19.0871C48.5643 18.4012 48.0666 17.7927 47.4804 17.3243L33.7501 8.64894C33.0754 8.32063 32.3121 8.13243 31.6263 8.28717L30.9404 8.44191L34.2415 23.0742L48.8738 19.773L48.719 19.0871Z"
+          fill="#0AADFF"
+        ></path>
+        <rect
+          x="41.7334"
+          y="40.3967"
+          width="37.6309"
+          height="28.6511"
+          transform="rotate(6.29289 41.7334 40.3967)"
+          fill="#66CCFF"
+        ></rect>
+        <path
+          d="M76.768 40.4721L44.4638 36.9097C42.3671 36.6785 40.548 38.2071 40.3254 40.2261L37.8591 62.5905C37.6279 64.6872 39.0788 66.4977 41.1755 66.729L73.4796 70.2913C75.4987 70.514 77.3869 69.0716 77.6181 66.9749L80.0843 44.6105C80.307 42.5915 78.787 40.6947 76.768 40.4721ZM73.4248 66.5125L42.0524 63.0529C41.7418 63.0187 41.6036 62.8462 41.6379 62.5356L44.0014 41.103C44.0271 40.8701 44.2081 40.6542 44.5187 40.6885L75.891 44.1481C76.124 44.1738 76.3312 44.4324 76.3056 44.6654L73.9421 66.098C73.9078 66.4086 73.6577 66.5382 73.4248 66.5125ZM49.9226 44.4284C48.1365 44.2314 46.6623 45.4836 46.4739 47.192C46.2769 48.978 47.4514 50.4437 49.2375 50.6407C50.9459 50.8291 52.4892 49.6631 52.6862 47.8771C52.8746 46.1687 51.631 44.6168 49.9226 44.4284ZM45.725 59.6852L70.5743 62.4255L71.2594 56.2131L65.1708 48.7036C64.8254 48.2725 64.2818 48.2126 63.8507 48.558L53.5908 56.7799L50.8186 53.4088C50.4732 52.9777 49.9296 52.9178 49.4985 53.2632L46.136 55.9578L45.725 59.6852Z"
+          fill="white"
+        ></path>
+        <path
+          d="M37.8174 63.0181L77.5892 66.862L77.012 72.8342C76.9057 73.9336 75.9283 74.7388 74.8288 74.6325L39.0385 71.1734C37.939 71.0671 37.1339 70.0897 37.2402 68.9902L37.8174 63.0181Z"
+          fill="white"
+        ></path>
+      </svg>
+    </div>
+    <div class="chat-upload-drop-zone__text">
+      <span class="chat-upload-drop-zone__text__title">
+        {{this.title}}
+      </span>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js
new file mode 100644
index 00000000000..3e6b2487685
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js
@@ -0,0 +1,19 @@
+import Component from "@glimmer/component";
+import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
+import I18n from "I18n";
+
+export default class ChatUploadDropZone extends Component {
+  get title() {
+    if (this.#isThread()) {
+      return I18n.t("chat.upload_to_thread");
+    } else {
+      return I18n.t("chat.upload_to_channel", {
+        title: this.args.model.title,
+      });
+    }
+  }
+
+  #isThread() {
+    return this.args.model instanceof ChatThread;
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js
new file mode 100644
index 00000000000..5f5e66e38d7
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js
@@ -0,0 +1,95 @@
+import ChatComposer from "../../chat-composer";
+import { inject as service } from "@ember/service";
+import I18n from "I18n";
+import discourseDebounce from "discourse-common/lib/debounce";
+import { action } from "@ember/object";
+import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
+import { Promise } from "rsvp";
+
+export default class ChatComposerChannel extends ChatComposer {
+  @service("chat-channel-composer") composer;
+  @service("chat-channel-pane") pane;
+
+  context = "channel";
+
+  @action
+  sendMessage(raw) {
+    const message = ChatMessage.createDraftMessage(this.args.channel, {
+      user: this.currentUser,
+      message: raw,
+    });
+
+    this.args.onSendMessage(message);
+
+    return Promise.resolve();
+  }
+
+  @action
+  persistDraft() {
+    if (this.args.channel?.isDraft) {
+      return;
+    }
+
+    this._persistHandler = discourseDebounce(
+      this,
+      this._debouncedPersistDraft,
+      2000
+    );
+  }
+
+  @action
+  _debouncedPersistDraft() {
+    this.chatApi.saveDraft(
+      this.args.channel.id,
+      this.currentMessage.toJSONDraft()
+    );
+  }
+
+  get placeholder() {
+    if (!this.args.channel.canModifyMessages(this.currentUser)) {
+      return I18n.t(
+        `chat.placeholder_new_message_disallowed.${this.args.channel.status}`
+      );
+    }
+
+    if (this.args.channel.isDraft) {
+      if (this.args.channel?.chatable?.users?.length) {
+        return I18n.t("chat.placeholder_start_conversation_users", {
+          commaSeparatedUsernames: this.args.channel.chatable.users
+            .mapBy("username")
+            .join(I18n.t("word_connector.comma")),
+        });
+      } else {
+        return I18n.t("chat.placeholder_start_conversation");
+      }
+    }
+
+    if (!this.chat.userCanInteractWithChat) {
+      return I18n.t("chat.placeholder_silenced");
+    } else {
+      return this.#messageRecipients(this.args.channel);
+    }
+  }
+
+  #messageRecipients(channel) {
+    if (channel.isDirectMessageChannel) {
+      const directMessageRecipients = channel.chatable.users;
+      if (
+        directMessageRecipients.length === 1 &&
+        directMessageRecipients[0].id === this.currentUser.id
+      ) {
+        return I18n.t("chat.placeholder_self");
+      }
+
+      return I18n.t("chat.placeholder_users", {
+        commaSeparatedNames: directMessageRecipients
+          .map((u) => u.name || `@${u.username}`)
+          .join(I18n.t("word_connector.comma")),
+      });
+    } else {
+      return I18n.t("chat.placeholder_channel", {
+        channelName: `#${channel.title}`,
+      });
+    }
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js
new file mode 100644
index 00000000000..76a6c15860c
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js
@@ -0,0 +1,30 @@
+import ChatComposer from "../../chat-composer";
+import { inject as service } from "@ember/service";
+import I18n from "I18n";
+import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
+import { Promise } from "rsvp";
+import { action } from "@ember/object";
+
+export default class ChatComposerThread extends ChatComposer {
+  @service("chat-channel-thread-composer") composer;
+  @service("chat-channel-thread-pane") pane;
+
+  context = "thread";
+
+  @action
+  sendMessage(raw) {
+    const message = ChatMessage.createDraftMessage(this.args.channel, {
+      user: this.currentUser,
+      message: raw,
+      thread_id: this.args.channel.activeThread.id,
+    });
+
+    this.args.onSendMessage(message);
+
+    return Promise.resolve();
+  }
+
+  get placeholder() {
+    return I18n.t("chat.placeholder_thread");
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js
index 60ce768975e..b70382d4274 100644
--- a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js
+++ b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js
@@ -163,7 +163,7 @@ export default Component.extend({
   @action
   handleFilterKeyUp(event) {
     if (event.key === "Tab") {
-      const enabledComposer = document.querySelector(".chat-composer-input");
+      const enabledComposer = document.querySelector(".chat-composer__input");
       if (enabledComposer && !enabledComposer.disabled) {
         event.preventDefault();
         event.stopPropagation();
diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs
index b30ea425d5a..97fc62b9593 100644
--- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs
@@ -1,5 +1,5 @@
 {{#if @channel.id}}
-  <ChatLivePane
+  <ChatChannel
     @channel={{@channel}}
     @targetMessageId={{readonly @targetMessageId}}
   />
diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js
index 5d91f205e4f..cd68b634d5c 100644
--- a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js
+++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js
@@ -8,15 +8,21 @@ registerUnbound("format-chat-date", function (message, mode) {
   const currentUser = User.current();
   const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess();
   const date = moment(new Date(message.createdAt), tz);
-  const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`);
-  const title = date.format(I18n.t("dates.long_with_year"));
 
+  const title = date.format(I18n.t("dates.long_with_year"));
   const display =
     mode === "tiny"
       ? date.format(I18n.t("chat.dates.time_tiny"))
       : date.format(I18n.t("dates.time"));
 
-  return htmlSafe(
-    `<a title='${title}' class='chat-time' href='${url}'>${display}</a>`
-  );
+  if (message.staged) {
+    return htmlSafe(
+      `<span title='${title}'  class='chat-time'>${display}</span>`
+    );
+  } else {
+    const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`);
+    return htmlSafe(
+      `<a title='${title}' class='chat-time' href='${url}'>${display}</a>`
+    );
+  }
 });
diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js
deleted file mode 100644
index 5d436f6a4f7..00000000000
--- a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
-import { generateCookFunction } from "discourse/lib/text";
-import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
-
-export default {
-  name: "chat-cook-function",
-
-  before: "chat-setup",
-
-  initialize(container) {
-    const site = container.lookup("service:site");
-
-    const markdownOptions = {
-      featuresOverride:
-        site.markdown_additional_options?.chat?.limited_pretty_text_features,
-      markdownItRules:
-        site.markdown_additional_options?.chat
-          ?.limited_pretty_text_markdown_rules,
-      hashtagTypesInPriorityOrder:
-        site.hashtag_configurations?.["chat-composer"],
-      hashtagIcons: site.hashtag_icons,
-    };
-
-    generateCookFunction(markdownOptions).then((cookFunction) => {
-      ChatMessage.cookFunction = (raw) => {
-        return simpleCategoryHashMentionTransform(
-          cookFunction(raw),
-          site.categories
-        );
-      };
-    });
-  },
-};
diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js
index 8439c53d8eb..b535c4e119d 100644
--- a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js
+++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js
@@ -42,7 +42,8 @@ export default {
       chatService.switchChannelUpOrDown("down");
     };
 
-    const isChatComposer = (el) => el.classList.contains("chat-composer-input");
+    const isChatComposer = (el) =>
+      el.classList.contains("chat-composer__input");
     const isInputSelection = (el) => {
       const inputs = ["input", "textarea", "select", "button"];
       const elementTagName = el?.tagName.toLowerCase();
@@ -58,7 +59,7 @@ export default {
       }
       event.preventDefault();
       event.stopPropagation();
-      appEvents.trigger("chat:modify-selection", {
+      appEvents.trigger("chat:modify-selection", event, {
         type,
         context: event.target.dataset.chatComposerContext,
       });
@@ -70,7 +71,9 @@ export default {
       }
       event.preventDefault();
       event.stopPropagation();
-      appEvents.trigger("chat:open-insert-link-modal", { event });
+      appEvents.trigger("chat:open-insert-link-modal", event, {
+        context: event.target.dataset.chatComposerContext,
+      });
     };
 
     const openChatDrawer = (event) => {
diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js
index 2be3b6aec36..ccf5aef3f14 100644
--- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js
+++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js
@@ -6,7 +6,7 @@ export default function chatMessageContainer(id, context) {
   if (context === MESSAGE_CONTEXT_THREAD) {
     selector = `.chat-thread .chat-message-container[data-id="${id}"]`;
   } else {
-    selector = `.chat-live-pane .chat-message-container[data-id="${id}"]`;
+    selector = `.chat-channel .chat-message-container[data-id="${id}"]`;
   }
 
   return document.querySelector(selector);
diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js
index 349b83f577e..d65a3e532ae 100644
--- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js
+++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js
@@ -349,12 +349,12 @@ export default class ChatMessageInteractor {
 
   @action
   reply() {
-    this.composer.setReplyTo(this.message.id);
+    this.composer.replyTo(this.message);
   }
 
   @action
   edit() {
-    this.composer.editButtonClicked(this.message.id);
+    this.composer.editMessage(this.message);
   }
 
   @action
diff --git a/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js
new file mode 100644
index 00000000000..025f9c316d8
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js
@@ -0,0 +1,72 @@
+import EmberObject from "@ember/object";
+import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation";
+import { next, schedule } from "@ember/runloop";
+import { setOwner } from "@ember/application";
+import { inject as service } from "@ember/service";
+
+// This class sole purpose is to provide a way to interact with the textarea
+// using the existing TextareaTextManipulation mixin without using it directly
+// in the composer component. It will make future migration easier.
+export default class TextareaInteractor extends EmberObject.extend(
+  TextareaTextManipulation
+) {
+  @service capabilities;
+  @service site;
+
+  constructor(owner, textarea) {
+    super(...arguments);
+    setOwner(this, owner);
+    this.textarea = textarea;
+    this._textarea = textarea;
+    this.element = this._textarea;
+    this.ready = true;
+  }
+
+  set value(value) {
+    this._textarea.value = value;
+    const event = new Event("input", {
+      bubbles: true,
+      cancelable: true,
+    });
+    this._textarea.dispatchEvent(event);
+  }
+
+  focus(opts = { ensureAtEnd: false, refreshHeight: true }) {
+    next(() => {
+      if (opts.refreshHeight) {
+        this.refreshHeight();
+      }
+
+      if (opts.ensureAtEnd) {
+        this.ensureCaretAtEnd();
+      }
+
+      if (this.capabilities.isIpadOS || this.site.mobileView) {
+        return;
+      }
+
+      this.focusTextArea();
+    });
+  }
+
+  ensureCaretAtEnd() {
+    schedule("afterRender", () => {
+      this._textarea.setSelectionRange(
+        this._textarea.value.length,
+        this._textarea.value.length
+      );
+    });
+  }
+
+  refreshHeight() {
+    schedule("afterRender", () => {
+      // this is a quirk which forces us to `auto` first or textarea
+      // won't resize
+      this._textarea.style.height = "auto";
+
+      // +1 is to workaround a rounding error visible on electron
+      // causing scrollbars to show when they shouldn’t
+      this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
+    });
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js
index 207174e7de9..76ba2a1209b 100644
--- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js
+++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js
@@ -8,6 +8,7 @@ import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel
 import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager";
 import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
 import { getOwner } from "discourse-common/lib/get-owner";
+import guid from "pretty-text/guid";
 
 export const CHATABLE_TYPES = {
   directMessageChannel: "DirectMessage",
@@ -78,6 +79,10 @@ export default class ChatChannel extends RestModel {
     this.messagesManager.messages = messages;
   }
 
+  get canLoadMoreFuture() {
+    return this.messagesManager.canLoadMoreFuture;
+  }
+
   get escapedTitle() {
     return escapeExpression(this.title);
   }
@@ -150,6 +155,27 @@ export default class ChatChannel extends RestModel {
     this.channelMessageBusLastId = details.channel_message_bus_last_id;
   }
 
+  stageMessage(message) {
+    message.id = guid();
+    message.staged = true;
+    message.draft = false;
+    message.createdAt ??= moment.utc().format();
+    message.cook();
+
+    if (message.inReplyTo) {
+      if (!message.inReplyTo.threadId) {
+        message.inReplyTo.threadId = guid();
+        message.inReplyTo.threadReplyCount = 1;
+      }
+
+      if (!this.threadingEnabled) {
+        this.messagesManager.addMessages([message]);
+      }
+    } else {
+      this.messagesManager.addMessages([message]);
+    }
+  }
+
   canModifyMessages(user) {
     if (user.staff) {
       return !STAFF_READONLY_STATUSES.includes(this.status);
diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js
deleted file mode 100644
index 00709add3f3..00000000000
--- a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { tracked } from "@glimmer/tracking";
-
-export default class ChatMessageDraft {
-  static create(args = {}) {
-    return new ChatMessageDraft(args ?? {});
-  }
-
-  @tracked uploads;
-  @tracked message;
-  @tracked _replyToMsg;
-
-  constructor(args = {}) {
-    this.message = args.message ?? "";
-    this.uploads = args.uploads ?? [];
-    this.replyToMsg = args.replyToMsg;
-  }
-
-  get replyToMsg() {
-    return this._replyToMsg;
-  }
-
-  set replyToMsg(message) {
-    this._replyToMsg = message
-      ? {
-          id: message.id,
-          excerpt: message.excerpt,
-          user: {
-            id: message.user.id,
-            name: message.user.name,
-            avatar_template: message.user.avatar_template,
-            username: message.user.username,
-          },
-        }
-      : null;
-  }
-
-  toJSON() {
-    if (
-      this.message?.length === 0 &&
-      this.uploads?.length === 0 &&
-      !this.replyToMsg
-    ) {
-      return null;
-    }
-
-    const data = {};
-
-    if (this.uploads?.length > 0) {
-      data.uploads = this.uploads;
-    }
-
-    if (this.message?.length > 0) {
-      data.message = this.message;
-    }
-
-    if (this.replyToMsg) {
-      data.replyToMsg = this.replyToMsg;
-    }
-
-    return JSON.stringify(data);
-  }
-}
diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js
index 814964baa0e..880e7d03feb 100644
--- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js
+++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js
@@ -4,7 +4,9 @@ import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
 import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
 import Bookmark from "discourse/models/bookmark";
 import I18n from "I18n";
-import guid from "pretty-text/guid";
+import { generateCookFunction } from "discourse/lib/text";
+import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
+import { getOwner } from "discourse-common/lib/get-owner";
 
 export default class ChatMessage {
   static cookFunction = null;
@@ -13,10 +15,9 @@ export default class ChatMessage {
     return new ChatMessage(channel, args);
   }
 
-  static createStagedMessage(channel, args = {}) {
-    args.id = guid();
-    args.staged = true;
-    return new ChatMessage(channel, args);
+  static createDraftMessage(channel, args = {}) {
+    args.draft = true;
+    return ChatMessage.create(channel, args);
   }
 
   @tracked id;
@@ -24,6 +25,7 @@ export default class ChatMessage {
   @tracked selected;
   @tracked channel;
   @tracked staged = false;
+  @tracked draft = false;
   @tracked channelId;
   @tracked createdAt;
   @tracked deletedAt;
@@ -69,11 +71,20 @@ export default class ChatMessage {
     this.reviewableId = args.reviewableId || args.reviewable_id;
     this.userFlagStatus = args.userFlagStatus || args.user_flag_status;
     this.inReplyTo =
-      args.inReplyTo || args.in_reply_to
-        ? ChatMessage.create(channel, args.in_reply_to)
-        : null;
-    this.message = args.message;
-    this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
+      args.inReplyTo ||
+      (args.in_reply_to || args.replyToMsg
+        ? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg)
+        : null);
+    this.draft = args.draft;
+    this.message = args.message || "";
+
+    if (args.cooked) {
+      this.cooked = args.cooked;
+    } else {
+      this.cooked = "";
+      this.cook();
+    }
+
     this.reactions = this.#initChatMessageReactionModel(
       args.id,
       args.reactions
@@ -83,6 +94,38 @@ export default class ChatMessage {
     this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
   }
 
+  cook() {
+    const site = getOwner(this).lookup("service:site");
+
+    const markdownOptions = {
+      featuresOverride:
+        site.markdown_additional_options?.chat?.limited_pretty_text_features,
+      markdownItRules:
+        site.markdown_additional_options?.chat
+          ?.limited_pretty_text_markdown_rules,
+      hashtagTypesInPriorityOrder:
+        site.hashtag_configurations?.["chat-composer"],
+      hashtagIcons: site.hashtag_icons,
+    };
+
+    if (ChatMessage.cookFunction) {
+      this.cooked = ChatMessage.cookFunction(this.message);
+      this.incrementVersion();
+    } else {
+      generateCookFunction(markdownOptions).then((cookFunction) => {
+        ChatMessage.cookFunction = (raw) => {
+          return simpleCategoryHashMentionTransform(
+            cookFunction(raw),
+            site.categories
+          );
+        };
+
+        this.cooked = ChatMessage.cookFunction(this.message);
+        this.incrementVersion();
+      });
+    }
+  }
+
   get threadRouteModels() {
     return [...this.channel.routeModels, this.threadId];
   }
@@ -134,6 +177,41 @@ export default class ChatMessage {
     this.version++;
   }
 
+  toJSONDraft() {
+    if (
+      this.message?.length === 0 &&
+      this.uploads?.length === 0 &&
+      !this.inReplyTo
+    ) {
+      return null;
+    }
+
+    const data = {};
+
+    if (this.uploads?.length > 0) {
+      data.uploads = this.uploads;
+    }
+
+    if (this.message?.length > 0) {
+      data.message = this.message;
+    }
+
+    if (this.inReplyTo) {
+      data.replyToMsg = {
+        id: this.inReplyTo.id,
+        excerpt: this.inReplyTo.excerpt,
+        user: {
+          id: this.inReplyTo.user.id,
+          name: this.inReplyTo.user.name,
+          avatar_template: this.inReplyTo.user.avatar_template,
+          username: this.inReplyTo.user.username,
+        },
+      };
+    }
+
+    return JSON.stringify(data);
+  }
+
   react(emoji, action, actor, currentUserId) {
     const selfReaction = actor.id === currentUserId;
     const existingReaction = this.reactions.find(
diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js
index 1ee2c07f79b..e44b4406780 100644
--- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js
+++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js
@@ -3,6 +3,7 @@ import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messa
 import User from "discourse/models/user";
 import { escapeExpression } from "discourse/lib/utilities";
 import { tracked } from "@glimmer/tracking";
+import guid from "pretty-text/guid";
 
 export const THREAD_STATUSES = {
   open: "open",
@@ -28,6 +29,16 @@ export default class ChatThread {
     this.originalMessage.user = this.originalMessageUser;
   }
 
+  stageMessage(message) {
+    message.id = guid();
+    message.staged = true;
+    message.draft = false;
+    message.createdAt ??= moment.utc().format();
+    message.cook();
+
+    this.messagesManager.addMessages([message]);
+  }
+
   get messages() {
     return this.messagesManager.messages;
   }
diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js
index 3ac6d835601..1ab09e0e100 100644
--- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js
+++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js
@@ -9,6 +9,7 @@ export default class ResizableNode extends Modifier {
   element = null;
   resizerSelector = null;
   didResizeContainer = null;
+  options = null;
 
   _originalWidth = 0;
   _originalHeight = 0;
@@ -22,10 +23,14 @@ export default class ResizableNode extends Modifier {
     registerDestructor(this, (instance) => instance.cleanup());
   }
 
-  modify(element, [resizerSelector, didResizeContainer]) {
+  modify(element, [resizerSelector, didResizeContainer, options = {}]) {
     this.resizerSelector = resizerSelector;
     this.element = element;
     this.didResizeContainer = didResizeContainer;
+    this.options = Object.assign(
+      { vertical: true, horizontal: true, position: true, mutate: true },
+      options
+    );
 
     this.element
       .querySelector(this.resizerSelector)
@@ -97,21 +102,29 @@ export default class ResizableNode extends Modifier {
 
     const newStyle = {};
 
-    if (width > MINIMUM_SIZE) {
+    if (this.options.horizontal && width > MINIMUM_SIZE) {
       newStyle.width = width + "px";
-      newStyle.left =
-        Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) +
-        "px";
+
+      if (this.options.position) {
+        newStyle.left =
+          Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) +
+          "px";
+      }
     }
 
-    if (height > MINIMUM_SIZE) {
+    if (this.options.vertical && height > MINIMUM_SIZE) {
       newStyle.height = height + "px";
-      newStyle.top =
-        Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) +
-        "px";
+
+      if (this.options.position) {
+        newStyle.top =
+          Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) +
+          "px";
+      }
     }
 
-    Object.assign(this.element.style, newStyle);
+    if (this.options.mutate) {
+      Object.assign(this.element.style, newStyle);
+    }
 
     this.didResizeContainer?.(this.element, { width, height });
   }
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js
index 7be02b5ed3c..3c29d8041f3 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js
@@ -299,7 +299,7 @@ export default class ChatApi extends Service {
   /**
    * Saves a draft for the channel, which includes message contents and uploads.
    * @param {number} channelId - The ID of the channel.
-   * @param {object} data - The draft data, see ChatMessageDraft.toJSON() for more details.
+   * @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details.
    * @returns {Promise}
    */
   saveDraft(channelId, data) {
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js
index e06ed71af0b..6afa1a96a76 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js
@@ -1,132 +1,60 @@
-import { debounce } from "discourse-common/utils/decorators";
 import { tracked } from "@glimmer/tracking";
 import Service, { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
 
 export default class ChatChannelComposer extends Service {
   @service chat;
   @service chatApi;
   @service chatComposerPresenceManager;
+  @service currentUser;
 
-  @tracked editingMessage = null;
-  @tracked replyToMsg = null;
-  @tracked linkedComponent = null;
+  @tracked _message;
 
-  reset() {
-    this.editingMessage = null;
-    this.replyToMsg = null;
-  }
-
-  get model() {
-    return this.chat.activeChannel;
-  }
-
-  setReplyTo(messageOrId) {
-    if (messageOrId) {
-      this.cancelEditing();
-
-      const message =
-        typeof messageOrId === "number"
-          ? this.model.messagesManager.findMessage(messageOrId)
-          : messageOrId;
-      this.replyToMsg = message;
-      this.focusComposer();
-    } else {
-      this.replyToMsg = null;
+  @action
+  cancel() {
+    if (this.message.editing) {
+      this.reset();
+    } else if (this.message.inReplyTo) {
+      this.message.inReplyTo = null;
     }
-
-    this.onComposerValueChange({ replyToMsg: this.replyToMsg });
   }
 
-  editButtonClicked(messageId) {
-    const message = this.model.messagesManager.findMessage(messageId);
-    this.editingMessage = message;
-
-    // TODO (martin) Move scrollToLatestMessage to live panel.
-    // this.scrollToLatestMessage();
-
-    this.focusComposer();
+  @action
+  reset(channel) {
+    this.message = ChatMessage.createDraftMessage(channel, {
+      user: this.currentUser,
+    });
   }
 
-  onComposerValueChange({
-    value,
-    uploads,
-    replyToMsg,
-    inProgressUploadsCount,
-  }) {
-    if (!this.model) {
-      return;
-    }
-
-    if (!this.editingMessage && !this.model.isDraft) {
-      if (typeof value !== "undefined" && this.model.draft) {
-        this.model.draft.message = value;
-      }
-
-      // only save the uploads to the draft if we are not still uploading other
-      // ones, otherwise we get into a cycle where we pass the draft uploads as
-      // existingUploads back to the upload component and cause in progress ones
-      // to be cancelled
-      if (
-        typeof uploads !== "undefined" &&
-        inProgressUploadsCount !== "undefined" &&
-        inProgressUploadsCount === 0 &&
-        this.model.draft
-      ) {
-        this.model.draft.uploads = uploads;
-      }
-
-      if (typeof replyToMsg !== "undefined" && this.model.draft) {
-        this.model.draft.replyToMsg = replyToMsg;
-      }
-    }
-
-    if (!this.model.isDraft) {
-      this.#reportReplyingPresence(value);
-    }
-
-    this._persistDraft();
+  @action
+  clear() {
+    this.message.message = "";
   }
 
-  cancelEditing() {
-    this.editingMessage = null;
+  @action
+  editMessage(message) {
+    this.chat.activeMessage = null;
+    message.editing = true;
+    this.message = message;
   }
 
-  registerFocusHandler(handlerFn) {
-    this.focusHandler = handlerFn;
+  @action
+  onCancelEditing() {
+    this.reset();
   }
 
-  focusComposer() {
-    this.focusHandler();
+  @action
+  replyTo(message) {
+    this.chat.activeMessage = null;
+    this.message.inReplyTo = message;
   }
 
-  #reportReplyingPresence(composerValue) {
-    if (this.#componentDeleted) {
-      return;
-    }
-
-    if (this.model.isDraft) {
-      return;
-    }
-
-    const replying = !this.editingMessage && !!composerValue;
-    this.chatComposerPresenceManager.notifyState(this.model.id, replying);
+  get message() {
+    return this._message;
   }
 
-  @debounce(2000)
-  _persistDraft() {
-    if (this.#componentDeleted || !this.model) {
-      return;
-    }
-
-    if (!this.model.draft) {
-      return;
-    }
-
-    return this.chatApi.saveDraft(this.model.id, this.model.draft.toJSON());
-  }
-
-  get #componentDeleted() {
-    // note I didn't set this in the new version, not sure yet what to do with it
-    // return this.linkedComponent._selfDeleted;
+  set message(message) {
+    this._message = message;
   }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js
index 91b118dbcff..86b29e08290 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js
@@ -1,6 +1,5 @@
 import { tracked } from "@glimmer/tracking";
 import { action } from "@ember/object";
-import { popupAjaxError } from "discourse/lib/ajax-error";
 import Service, { inject as service } from "@ember/service";
 
 export default class ChatChannelPane extends Service {
@@ -13,7 +12,7 @@ export default class ChatChannelPane extends Service {
   @tracked reacting = false;
   @tracked selectingMessages = false;
   @tracked lastSelectedMessage = null;
-  @tracked sendingLoading = false;
+  @tracked sending = false;
 
   get selectedMessageIds() {
     return this.chat.activeChannel?.selectedMessages?.mapBy("id") || [];
@@ -23,6 +22,10 @@ export default class ChatChannelPane extends Service {
     return this.chatChannelComposer;
   }
 
+  get channel() {
+    return this.chat.activeChannel;
+  }
+
   @action
   cancelSelecting(selectedMessages) {
     this.selectingMessages = false;
@@ -37,55 +40,19 @@ export default class ChatChannelPane extends Service {
     this.selectingMessages = true;
   }
 
-  @action
-  editMessage(newContent, uploads) {
-    this.sendingLoading = true;
-    let data = {
-      new_message: newContent,
-      upload_ids: (uploads || []).map((upload) => upload.id),
-    };
-    return this.chatApi
-      .editMessage(
-        this.composerService.editingMessage.channelId,
-        this.composerService.editingMessage.id,
-        data
-      )
-      .then(() => {
-        this.resetAfterSend();
-      })
-      .catch(popupAjaxError)
-      .finally(() => {
-        if (this._selfDeleted) {
-          return;
-        }
-        this.sendingLoading = false;
-      });
-  }
-
-  resetAfterSend() {
-    const channelId = this.composerService.editingMessage?.channelId;
-    if (channelId) {
-      this.chatComposerPresenceManager.notifyState(channelId, false);
-    }
-
-    this.composerService.reset();
-  }
-
-  @action
-  editLastMessageRequested() {
-    const lastUserMessage = this.chat.activeChannel.messages.findLast(
+  get lastCurrentUserMessage() {
+    const lastCurrentUserMessage = this.chat.activeChannel.messages.findLast(
       (message) => message.user.id === this.currentUser.id
     );
 
-    if (!lastUserMessage) {
+    if (!lastCurrentUserMessage) {
       return;
     }
 
-    if (lastUserMessage.staged || lastUserMessage.error) {
+    if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) {
       return;
     }
 
-    this.composerService.editingMessage = lastUserMessage;
-    this.composerService.focusComposer();
+    return lastCurrentUserMessage;
   }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js
index bd691b7ce44..81749d4e725 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js
@@ -1,15 +1,13 @@
 import ChatChannelComposer from "./chat-channel-composer";
+import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
+import { action } from "@ember/object";
 
 export default class extends ChatChannelComposer {
-  get model() {
-    return this.chat.activeChannel.activeThread;
-  }
-
-  _persistDraft() {
-    // eslint-disable-next-line no-console
-    console.debug(
-      "Drafts are unsupported for chat threads at this point in time"
-    );
-    return;
+  @action
+  reset(channel) {
+    this.message = ChatMessage.createDraftMessage(channel, {
+      user: this.currentUser,
+      thread_id: channel.activeThread.id,
+    });
   }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js
index 839f690fb85..dd39dc6dffc 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js
@@ -11,4 +11,21 @@ export default class ChatChannelThreadPane extends ChatChannelPane {
   get composerService() {
     return this.chatChannelThreadComposer;
   }
+
+  get lastCurrentUserMessage() {
+    const lastCurrentUserMessage =
+      this.chat.activeChannel.activeThread.messages.findLast(
+        (message) => message.user.id === this.currentUser.id
+      );
+
+    if (!lastCurrentUserMessage) {
+      return;
+    }
+
+    if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) {
+      return;
+    }
+
+    return lastCurrentUserMessage;
+  }
 }
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js
new file mode 100644
index 00000000000..e09e354fc67
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js
@@ -0,0 +1,24 @@
+import Service from "@ember/service";
+import KeyValueStore from "discourse/lib/key-value-store";
+
+export default class ChatSidePanelSize extends Service {
+  STORE_NAMESPACE = "discourse_chat_side_panel_size_";
+  MIN_WIDTH = 250;
+
+  store = new KeyValueStore(this.STORE_NAMESPACE);
+
+  get width() {
+    return this.store.getObject("width") || this.MIN_WIDTH;
+  }
+
+  set width(width) {
+    this.store.setObject({
+      key: "width",
+      value: this.#min(width, this.MIN_WIDTH),
+    });
+  }
+
+  #min(number, min) {
+    return Math.max(number, min);
+  }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js
index c5e58e52c44..169a2bbdadd 100644
--- a/plugins/chat/assets/javascripts/discourse/services/chat.js
+++ b/plugins/chat/assets/javascripts/discourse/services/chat.js
@@ -8,7 +8,7 @@ import { cancel, next } from "@ember/runloop";
 import { and } from "@ember/object/computed";
 import { computed } from "@ember/object";
 import discourseLater from "discourse-common/lib/later";
-import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
+import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
 
 const CHAT_ONLINE_OPTIONS = {
   userUnseenTime: 300000, // 5 minutes seconds with no interaction
@@ -126,8 +126,15 @@ export default class Chat extends Service {
           const storedDraft = this.currentUser.chat_drafts.find(
             (draft) => draft.channel_id === channel.id
           );
-          channel.draft = ChatMessageDraft.create(
-            storedDraft ? JSON.parse(storedDraft.data) : null
+
+          channel.draft = ChatMessage.createDraftMessage(
+            channel,
+            Object.assign(
+              {
+                user: this.currentUser,
+              },
+              storedDraft ? JSON.parse(storedDraft.data) : {}
+            )
           );
         }
 
diff --git a/plugins/chat/assets/stylesheets/common/base-common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss
index af91d41e948..3ec626ce2a3 100644
--- a/plugins/chat/assets/stylesheets/common/base-common.scss
+++ b/plugins/chat/assets/stylesheets/common/base-common.scss
@@ -145,13 +145,9 @@ $float-height: 530px;
   word-wrap: break-word;
   white-space: normal;
   position: relative;
-  will-change: transform;
-  transform: translateZ(0);
 
   .chat-message-container {
     display: grid;
-    will-change: transform;
-    transform: translateZ(0);
 
     &.selecting-messages {
       grid-template-columns: 1.5em 1fr;
@@ -256,7 +252,7 @@ $float-height: 530px;
   }
 }
 
-.chat-live-pane {
+.chat-channel {
   display: flex;
   flex-direction: column;
   width: 100%;
@@ -432,9 +428,8 @@ body.has-full-page-chat {
     }
   }
 
-  .chat-live-pane,
   .chat-messages-scroll,
-  .chat-live-pane {
+  .chat-channel {
     box-sizing: border-box;
     height: 100%;
   }
@@ -629,7 +624,7 @@ html.has-full-page-chat {
     }
 
     .full-page-chat,
-    .chat-live-pane,
+    .chat-channel,
     #main-outlet {
       // allows containers to shrink to fit
       min-height: 0;
diff --git a/plugins/chat/assets/stylesheets/common/chat-channel.scss b/plugins/chat/assets/stylesheets/common/chat-channel.scss
new file mode 100644
index 00000000000..3db4cb4eabb
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/common/chat-channel.scss
@@ -0,0 +1,127 @@
+.chat-channel {
+  display: flex;
+  flex-direction: column;
+  min-height: 1px;
+  position: relative;
+  overflow: hidden;
+  grid-area: main;
+  width: 100%;
+  min-width: 300px;
+
+  .open-drawer-btn {
+    color: var(--primary-low-mid);
+
+    &:visited {
+      color: var(--primary-low-mid);
+    }
+
+    &:hover {
+      color: var(--primary);
+    }
+
+    > * {
+      pointer-events: none;
+    }
+  }
+
+  .chat-messages-scroll {
+    flex-grow: 1;
+    overflow-y: scroll;
+    overscroll-behavior: contain;
+    display: flex;
+    flex-direction: column-reverse;
+    z-index: 1;
+    margin: 0 1px 0 0;
+    will-change: transform;
+    @include chat-scrollbar();
+
+    .join-channel-btn.in-float {
+      position: absolute;
+      transform: translateX(-50%);
+      left: 50%;
+      top: 10px;
+      z-index: 10;
+    }
+
+    .all-loaded-message {
+      text-align: center;
+      color: var(--primary-medium);
+      font-size: var(--font-down-1);
+      padding: 0.5em 0.25em 0.25em;
+    }
+  }
+
+  .scroll-stick-wrap {
+    display: flex;
+    justify-content: center;
+    margin: 0 1rem;
+    position: relative;
+  }
+
+  .chat-scroll-to-bottom {
+    align-items: center;
+    justify-content: center;
+    position: absolute;
+    z-index: 1;
+    flex-direction: column;
+    bottom: -25px;
+    background: none;
+    opacity: 0;
+    transition: opacity 0.25s ease, transform 0.5s ease;
+    transform: scale(0.1);
+    padding: 0;
+
+    > * {
+      pointer-events: none;
+    }
+
+    &:hover,
+    &:active,
+    &:focus {
+      background: none !important;
+    }
+
+    &.visible {
+      transform: translateY(-32px) scale(1);
+      opacity: 0.8;
+    }
+
+    &__text {
+      color: var(--secondary);
+      padding: 0.5rem;
+      margin-bottom: 0.5rem;
+      background: var(--primary-medium);
+      border-radius: 3px;
+      text-align: center;
+      font-size: var(--font-down-1);
+      bottom: 40px;
+      position: absolute;
+    }
+
+    &__arrow {
+      display: flex;
+      background: var(--primary-medium);
+      border-radius: 100%;
+      align-items: center;
+      justify-content: center;
+      height: 32px;
+      width: 32px;
+      position: relative;
+
+      .d-icon {
+        color: var(--secondary);
+        margin-left: 1px; // "fixes" the 1px svg shift
+      }
+    }
+
+    &:hover {
+      opacity: 1;
+
+      .chat-scroll-to-bottom__arrow {
+        .d-icon {
+          color: var(--secondary);
+        }
+      }
+    }
+  }
+}
diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss
index 5d4b36303d2..0b1ef875bb2 100644
--- a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss
@@ -1,30 +1,62 @@
 .chat-composer-upload {
   display: inline-flex;
-  height: 50px;
+  height: 64px;
   padding: 0.5rem;
   border: 1px solid var(--primary-low-mid);
   margin-right: 0.5em;
+  position: relative;
+  border-radius: 5px;
+  box-sizing: border-box;
+
+  &--image:not(.chat-composer-upload--in-progress) {
+    padding: 0;
+
+    .preview-img {
+      height: 62px;
+      width: 62px;
+      box-sizing: border-box;
+    }
+  }
 
   &:last-child {
     margin-right: 0;
   }
 
+  &:hover {
+    .chat-composer-upload__remove-btn {
+      visibility: visible;
+      background: rgba(var(--always-black-rgb), 0.9);
+      padding: 5px;
+      border-radius: 100%;
+      font-size: var(--font-down-2);
+    }
+  }
+
+  &__remove-btn {
+    border: 1px solid var(--primary-medium);
+    position: absolute;
+    top: -8px;
+    right: -8px;
+    visibility: hidden;
+  }
+
   .preview {
-    width: 50px;
+    width: 100%;
     height: 100%;
     display: flex;
     align-items: center;
     justify-content: center;
-    margin: 0 1em 0 0;
-    border-radius: 8px;
+    margin: 0;
 
     .d-icon {
       font-size: var(--font-up-6);
+      margin-right: 0.5rem;
     }
 
     .preview-img {
-      max-width: 100%;
-      max-height: 100%;
+      object-position: center;
+      object-fit: cover;
+      border-radius: 5px;
     }
   }
 
diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss
index bdae3a83f08..81693b18010 100644
--- a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss
@@ -2,7 +2,7 @@
   max-width: 100%;
 
   .chat-composer-uploads-container {
-    padding: 0.5rem 10px;
+    padding: 0.5rem 0.25rem;
     display: flex;
     white-space: nowrap;
     overflow-x: auto;
diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss
index 2077128b215..8a020f1fe35 100644
--- a/plugins/chat/assets/stylesheets/common/chat-composer.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss
@@ -1,60 +1,104 @@
-.chat-composer-container {
-  display: flex;
-  flex-direction: column;
-  z-index: 3;
-  background-color: var(--secondary);
-
-  #chat-full-page-uploader,
-  #chat-widget-uploader {
-    display: none;
-  }
-
-  .drop-a-file {
-    display: none;
-  }
-}
-
 .chat-composer {
   display: flex;
   align-items: center;
-  background-color: var(--secondary);
-  border: 1px solid var(--primary-low-mid);
-  border-radius: 5px;
-  padding: 0.15rem 0.25rem;
-  margin-top: 0.5rem;
 
-  &.is-disabled {
-    background-color: var(--primary-low);
-    border: 1px solid var(--primary-low-mid);
+  &__wrapper {
+    display: flex;
+    flex-direction: column;
+    z-index: 3;
+    background-color: var(--secondary);
+    margin-top: 0.1rem;
+
+    #chat-full-page-uploader,
+    #chat-widget-uploader {
+      display: none;
+    }
+
+    .drop-a-file {
+      display: none;
+    }
   }
 
-  .send-btn {
-    padding: 0.4rem 0.5rem;
-    border: 1px solid transparent;
-    border-radius: 5px;
+  &__outer-container {
     display: flex;
     align-items: center;
+    padding-inline: 0.25rem;
+    box-sizing: border-box;
+    width: 100%;
+  }
 
-    .d-icon {
-      color: var(--tertiary);
+  &__inner-container {
+    display: flex;
+    align-items: center;
+    box-sizing: border-box;
+    width: 100%;
+    flex-direction: row;
+    border: 1px solid var(--primary-low-mid);
+    border-radius: 5px;
+    background-color: var(--secondary);
+    padding-inline: 0.25rem;
+    height: 42px;
+
+    .chat-composer--focused & {
+      border-color: var(--primary-medium);
     }
 
-    &:disabled {
-      cursor: not-allowed;
+    .chat-composer--disabled & {
+      background: var(--primary-low);
+    }
+  }
+
+  &__send-btn {
+    border-radius: 3px;
+    background: none;
+    will-change: scale;
+
+    .chat-composer--send-enabled & {
+      &:hover {
+        background: none;
+      }
+
+      &:focus {
+        background: none;
+        outline: auto;
+      }
 
       .d-icon {
-        color: var(--primary-low);
+        color: var(--tertiary) !important;
       }
     }
 
-    &:not(:disabled) {
-      &:hover,
-      &:focus {
-        background: var(--tertiary);
-        .d-icon {
-          color: var(--secondary);
-        }
+    @keyframes sendingScales {
+      0% {
+        transform: scale(0.8);
       }
+      50% {
+        transform: scale(1.2);
+      }
+      100% {
+        transform: scale(0.8);
+      }
+    }
+
+    .chat-composer--sending & {
+      animation: sendingScales 1s infinite;
+    }
+
+    .chat-composer--send-disabled & {
+      cursor: default;
+      opacity: 0.6 !important;
+
+      &:hover {
+        background: none !important;
+      }
+    }
+
+    > * {
+      pointer-events: none;
+    }
+
+    .d-icon {
+      color: var(--primary) !important;
     }
   }
 
@@ -82,7 +126,14 @@
     }
   }
 
-  .chat-composer-input {
+  &__input-container {
+    display: flex;
+    align-items: center;
+    box-sizing: border-box;
+    width: 100%;
+  }
+
+  &__input {
     overflow-x: hidden;
     width: 100%;
     appearance: none;
@@ -97,14 +148,21 @@
 
     @include chat-scrollbar();
 
+    &[disabled] {
+      background: none;
+    }
+
+    &:focus,
+    &:active {
+      outline: none;
+    }
+
     &:placeholder-shown,
     &::placeholder {
       overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
     }
-
-    @include chat-scrollbar();
   }
 
   &__unreliable-network {
diff --git a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss
index cc2510d9bea..6f5e4d1f9e9 100644
--- a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss
@@ -37,7 +37,7 @@
     }
   }
 
-  .chat-composer-container {
+  .chat-composer__wrapper {
     padding-bottom: 0.5em;
   }
 }
diff --git a/plugins/chat/assets/stylesheets/common/chat-drawer.scss b/plugins/chat/assets/stylesheets/common/chat-drawer.scss
index 296491de2c9..be3f22e40e4 100644
--- a/plugins/chat/assets/stylesheets/common/chat-drawer.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss
@@ -94,7 +94,7 @@ html.rtl {
     height: auto !important;
   }
 
-  .chat-live-pane {
+  .chat-channel {
     height: 100%;
   }
 }
diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss
new file mode 100644
index 00000000000..291d9e53c1b
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss
@@ -0,0 +1,16 @@
+.chat-side-panel-resizer {
+  top: 0;
+  bottom: 0;
+  left: -3px;
+  width: 5px;
+  position: absolute;
+  z-index: z("max");
+  transition: background-color 0.15s 0.15s;
+  background-color: transparent;
+
+  &:hover,
+  &:active {
+    cursor: col-resize;
+    background: var(--tertiary);
+  }
+}
diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss
index 09e2e22c3e0..f3139712ab6 100644
--- a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss
@@ -1,13 +1,9 @@
 #main-chat-outlet.chat-view {
   min-height: 0;
   display: grid;
-  grid-template-rows: 1fr;
+  grid-template-rows: 100%;
   grid-template-areas: "main threads";
-  grid-template-columns: 1fr;
-
-  &.has-side-panel-expanded {
-    grid-template-columns: 3fr 2fr;
-  }
+  grid-template-columns: 1fr auto;
 }
 
 .chat-side-panel {
@@ -15,6 +11,8 @@
   min-height: 100%;
   box-sizing: border-box;
   border-left: 1px solid var(--primary-low);
+  position: relative;
+  min-width: 250px;
 
   &__list {
     flex-grow: 1;
diff --git a/plugins/chat/assets/stylesheets/common/chat-thread.scss b/plugins/chat/assets/stylesheets/common/chat-thread.scss
index 4204eeead4b..d124fbd8ad4 100644
--- a/plugins/chat/assets/stylesheets/common/chat-thread.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-thread.scss
@@ -2,6 +2,7 @@
   display: flex;
   flex-direction: column;
   height: 100%;
+  position: relative;
 
   &__header {
     height: var(--chat-header-offset);
@@ -27,4 +28,8 @@
     flex-direction: column-reverse;
     will-change: transform;
   }
+
+  .chat-composer {
+    padding-bottom: 28px;
+  }
 }
diff --git a/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss
new file mode 100644
index 00000000000..b313056f3aa
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss
@@ -0,0 +1,77 @@
+.chat-upload-drop-zone {
+  position: absolute;
+  visibility: hidden;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: z("max");
+  align-items: center;
+  justify-content: center;
+  display: flex;
+  background: rgba(var(--always-black-rgb), 0.85);
+
+  .uppy-is-drag-over & {
+    visibility: visible;
+  }
+
+  &__content {
+    position: relative;
+    width: 50%;
+    height: 50%;
+  }
+
+  &__background {
+    svg {
+      transform: scale(0.1);
+      transition: transform 200ms ease-in-out;
+      height: 80px;
+
+      .uppy-is-drag-over & {
+        transform: scale(1);
+      }
+    }
+
+    position: absolute;
+    top: 0;
+    left: calc(50% - 100px / 2);
+    z-index: 1;
+  }
+
+  &__illustration {
+    svg {
+      transform: scale(0.1);
+      transition: transform 200ms ease-in-out;
+      height: 80px;
+
+      .uppy-is-drag-over & {
+        transform: scale(1);
+      }
+    }
+
+    position: absolute;
+    top: 0;
+    left: calc(50% - 100px / 2);
+    z-index: 1;
+  }
+
+  &__text {
+    position: absolute;
+    top: 100px;
+    left: 0;
+    right: 0;
+    width: 100%;
+    z-index: 1;
+    display: flex;
+    justify-content: center;
+
+    &__title {
+      width: 100%;
+      font-weight: 600;
+      text-align: center;
+      font-size: var(--font-up-2);
+      padding-inline: 1rem;
+      color: var(--secondary-or-primary);
+    }
+  }
+}
diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss
index 6f5cad90861..713e5ef5460 100644
--- a/plugins/chat/assets/stylesheets/common/index.scss
+++ b/plugins/chat/assets/stylesheets/common/index.scss
@@ -1,6 +1,7 @@
 @import "base-common";
 @import "sidebar-extensions";
 @import "chat-browse";
+@import "chat-channel";
 @import "chat-channel-card";
 @import "chat-channel-info";
 @import "chat-channel-preview-card";
@@ -35,6 +36,8 @@
 @import "chat-skeleton";
 @import "chat-tabs";
 @import "chat-thread";
+@import "chat-side-panel-resizer";
+@import "chat-upload-drop-zone";
 @import "chat-transcript";
 @import "core-extensions";
 @import "create-channel-modal";
diff --git a/plugins/chat/assets/stylesheets/desktop/base-desktop.scss b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss
index 3eb8d02902b..121e85ab6e5 100644
--- a/plugins/chat/assets/stylesheets/desktop/base-desktop.scss
+++ b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss
@@ -7,7 +7,7 @@
   &.teams-sidebar-on {
     grid-template-columns: 1fr;
 
-    .chat-live-pane {
+    .chat-channel {
       border-radius: var(--full-page-border-radius);
     }
   }
@@ -19,7 +19,7 @@
     flex-shrink: 0;
   }
 
-  .chat-live-pane {
+  .chat-channel {
     .chat-messages-container {
       .chat-message {
         &.is-reply {
@@ -87,7 +87,7 @@
     border-right: 1px solid var(--primary-low);
     border-left: 1px solid var(--primary-low);
 
-    .chat-live-pane {
+    .chat-channel {
       border-radius: unset;
     }
   }
@@ -112,7 +112,7 @@
   }
 
   .full-page-chat.teams-sidebar-on {
-    .chat-live-pane {
+    .chat-channel {
       border-radius: 0;
     }
 
diff --git a/plugins/chat/assets/stylesheets/mobile/base-mobile.scss b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss
index 107c8629dcc..d44ae726388 100644
--- a/plugins/chat/assets/stylesheets/mobile/base-mobile.scss
+++ b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss
@@ -15,7 +15,7 @@ html.has-full-page-chat {
     padding: 0;
 
     .main-chat-outlet {
-      .chat-live-pane {
+      .chat-channel {
         min-width: 0;
       }
 
@@ -23,7 +23,7 @@ html.has-full-page-chat {
         grid-template-columns: 1fr;
         grid-template-areas: "threads";
 
-        .chat-live-pane {
+        .chat-channel {
           display: none;
         }
       }
@@ -45,7 +45,7 @@ html.has-full-page-chat {
     }
   }
 
-  .chat-live-pane {
+  .chat-channel {
     border-radius: 0;
     padding: 0;
   }
diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss
new file mode 100644
index 00000000000..b99e9877373
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss
@@ -0,0 +1,11 @@
+.chat-composer-upload {
+  &__remove-btn {
+    visibility: visible;
+    background: rgba(var(--always-black-rgb), 0.9);
+    border-radius: 100%;
+
+    // overwrite ios style
+    font-size: var(--font-down-2) !important;
+    padding: 5px !important;
+  }
+}
diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss
index c6f772e9b60..84874803e56 100644
--- a/plugins/chat/assets/stylesheets/mobile/chat-message.scss
+++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss
@@ -2,7 +2,7 @@
   #skip-link,
   .d-header,
   .chat-message-actions-mobile-outlet,
-  .chat-live-pane,
+  .chat-channel,
   .chat-thread {
     > * {
       @include user-select(none);
diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss
index 1fa0be669d6..90fce33e9d0 100644
--- a/plugins/chat/assets/stylesheets/mobile/index.scss
+++ b/plugins/chat/assets/stylesheets/mobile/index.scss
@@ -6,3 +6,4 @@
 @import "chat-message";
 @import "chat-selection-manager";
 @import "chat-emoji-picker";
+@import "chat-composer-upload";
diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml
index 4ccb13f1199..3261b7f2ac2 100644
--- a/plugins/chat/config/locales/client.en.yml
+++ b/plugins/chat/config/locales/client.en.yml
@@ -221,7 +221,8 @@ en:
       close_full_page: "Close full-screen chat"
       open_message: "Open message in chat"
       placeholder_self: "Jot something down"
-      placeholder_channel: "Chat with %{channelName}"
+      placeholder_channel: "Chat in %{channelName}"
+      placeholder_thread: "Chat in thread"
       placeholder_users: "Chat with %{commaSeparatedNames}"
       placeholder_new_message_disallowed:
         archived: "Channel is archived, you cannot send new messages right now."
@@ -257,6 +258,8 @@ en:
       title: "chat"
       title_capitalized: "Chat"
       upload: "Attach a file"
+      upload_to_channel: "Upload to %{title}"
+      upload_to_thread: "Upload to thread"
       uploaded_files:
         one: "%{count} file"
         other: "%{count} files"
@@ -399,6 +402,7 @@ en:
         italic_text: "emphasized text"
         bold_text: "strong text"
         code_text: "code text"
+        send: "Send"
 
       quote:
         original_channel: 'Originally sent in <a href="%{channelLink}">%{channel}</a>'
@@ -539,6 +543,7 @@ en:
           one: "%{count} reply"
           other: "%{count} replies"
         label: Thread
+        close: "Close Thread"
       threads:
         started_by: "Started by"
         open: "Open Thread"
diff --git a/plugins/chat/spec/system/chat_composer_spec.rb b/plugins/chat/spec/system/chat_composer_spec.rb
index fde6408d1a0..22d59dd6e55 100644
--- a/plugins/chat/spec/system/chat_composer_spec.rb
+++ b/plugins/chat/spec/system/chat_composer_spec.rb
@@ -10,10 +10,83 @@ RSpec.describe "Chat composer", type: :system, js: true do
 
   before { chat_system_bootstrap }
 
-  xit "it stores draft in replies" do
-  end
+  context "when loading a channel with a draft" do
+    fab!(:draft_1) do
+      Chat::Draft.create!(
+        chat_channel: channel_1,
+        user: current_user,
+        data: { message: "draft" }.to_json,
+      )
+    end
 
-  xit "it stores draft" do
+    before do
+      channel_1.add(current_user)
+      sign_in(current_user)
+    end
+
+    it "loads the draft" do
+      chat.visit_channel(channel_1)
+
+      expect(find(".chat-composer__input").value).to eq("draft")
+    end
+
+    context "with uploads" do
+      fab!(:upload_1) do
+        Fabricate(
+          :upload,
+          url: "/images/logo-dark.png",
+          original_filename: "logo_dark.png",
+          width: 400,
+          height: 300,
+          extension: "png",
+        )
+      end
+
+      fab!(:draft_1) do
+        Chat::Draft.create!(
+          chat_channel: channel_1,
+          user: current_user,
+          data: { message: "draft", uploads: [upload_1] }.to_json,
+        )
+      end
+
+      it "loads the draft with the upload" do
+        chat.visit_channel(channel_1)
+
+        expect(find(".chat-composer__input").value).to eq("draft")
+        expect(page).to have_selector(".chat-composer-upload--image", count: 1)
+      end
+    end
+
+    context "when replying" do
+      fab!(:draft_1) do
+        Chat::Draft.create!(
+          chat_channel: channel_1,
+          user: current_user,
+          data: {
+            message: "draft",
+            replyToMsg: {
+              id: message_1.id,
+              excerpt: message_1.excerpt,
+              user: {
+                id: message_1.user.id,
+                name: nil,
+                avatar_template: message_1.user.avatar_template,
+                username: message_1.user.username,
+              },
+            },
+          }.to_json,
+        )
+      end
+
+      it "loads the draft with replied to mesage" do
+        chat.visit_channel(channel_1)
+
+        expect(find(".chat-composer__input").value).to eq("draft")
+        expect(page).to have_selector(".chat-reply__username", text: message_1.user.username)
+        expect(page).to have_selector(".chat-reply__excerpt", text: message_1.excerpt)
+      end
+    end
   end
 
   context "when replying to a message" do
@@ -62,17 +135,17 @@ RSpec.describe "Chat composer", type: :system, js: true do
         ".chat-composer-message-details .chat-reply__username",
         text: current_user.username,
       )
-      expect(find(".chat-composer-input").value).to eq(message_2.message)
+      expect(find(".chat-composer__input").value).to eq(message_2.message)
     end
 
     context "when pressing escape" do
       it "cancels editing" do
         chat.visit_channel(channel_1)
         channel.edit_message(message_2)
-        find(".chat-composer-input").send_keys(:escape)
+        find(".chat-composer__input").send_keys(:escape)
 
         expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
-        expect(find(".chat-composer-input").value).to eq("")
+        expect(find(".chat-composer__input").value).to eq("")
       end
     end
 
@@ -83,7 +156,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
         find(".cancel-message-action").click
 
         expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
-        expect(find(".chat-composer-input").value).to eq("")
+        expect(find(".chat-composer__input").value).to eq("")
       end
     end
   end
@@ -100,7 +173,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
       channel.click_action_button("emoji")
       find("[data-emoji='grimacing']").click(wait: 0.5)
 
-      expect(find(".chat-composer-input").value).to eq(":grimacing:")
+      expect(find(".chat-composer__input").value).to eq(":grimacing:")
     end
 
     it "removes denied emojis from insert emoji picker" do
@@ -123,20 +196,20 @@ RSpec.describe "Chat composer", type: :system, js: true do
 
     it "adds the emoji to the composer" do
       chat.visit_channel(channel_1)
-      find(".chat-composer-input").fill_in(with: ":gri")
+      find(".chat-composer__input").fill_in(with: ":gri")
       find(".emoji-shortname", text: "grimacing").click
 
-      expect(find(".chat-composer-input").value).to eq(":grimacing: ")
+      expect(find(".chat-composer__input").value).to eq(":grimacing: ")
     end
 
     it "doesn't suggest denied emojis and aliases" do
       SiteSetting.emoji_deny_list = "peach|poop"
       chat.visit_channel(channel_1)
 
-      find(".chat-composer-input").fill_in(with: ":peac")
+      find(".chat-composer__input").fill_in(with: ":peac")
       expect(page).to have_no_selector(".emoji-shortname", text: "peach")
 
-      find(".chat-composer-input").fill_in(with: ":hank") # alias
+      find(".chat-composer__input").fill_in(with: ":hank") # alias
       expect(page).to have_no_selector(".emoji-shortname", text: "poop")
     end
   end
@@ -149,7 +222,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
 
     xit "prefills the emoji picker filter input" do
       chat.visit_channel(channel_1)
-      find(".chat-composer-input").fill_in(with: ":gri")
+      find(".chat-composer__input").fill_in(with: ":gri")
 
       click_link(I18n.t("js.composer.more_emoji"))
 
@@ -158,7 +231,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
 
     it "filters with the prefilled input" do
       chat.visit_channel(channel_1)
-      find(".chat-composer-input").fill_in(with: ":fr")
+      find(".chat-composer__input").fill_in(with: ":fr")
 
       click_link(I18n.t("js.composer.more_emoji"))
 
@@ -178,15 +251,15 @@ RSpec.describe "Chat composer", type: :system, js: true do
 
       find("body").send_keys("b")
 
-      expect(find(".chat-composer-input").value).to eq("b")
+      expect(find(".chat-composer__input").value).to eq("b")
 
       find("body").send_keys("b")
 
-      expect(find(".chat-composer-input").value).to eq("bb")
+      expect(find(".chat-composer__input").value).to eq("bb")
 
       find("body").send_keys(:enter) # special case
 
-      expect(find(".chat-composer-input").value).to eq("bb")
+      expect(find(".chat-composer__input").value).to eq("bb")
     end
   end
 end
diff --git a/plugins/chat/spec/system/chat_message/channel_spec.rb b/plugins/chat/spec/system/chat_message/channel_spec.rb
index 39d9556bd06..a91c6b94511 100644
--- a/plugins/chat/spec/system/chat_message/channel_spec.rb
+++ b/plugins/chat/spec/system/chat_message/channel_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe "Chat message", type: :system, js: true do
       channel.hover_message(message_1)
 
       expect(page).to have_css(
-        ".chat-live-pane[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
+        ".chat-channel[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
       )
     end
   end
diff --git a/plugins/chat/spec/system/chat_message/thread_spec.rb b/plugins/chat/spec/system/chat_message/thread_spec.rb
index aaa8aa1287a..187a6aef2cc 100644
--- a/plugins/chat/spec/system/chat_message/thread_spec.rb
+++ b/plugins/chat/spec/system/chat_message/thread_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe "Chat message - channel", type: :system, js: true do
   let(:cdp) { PageObjects::CDP.new }
   let(:chat) { PageObjects::Pages::Chat.new }
   let(:channel) { PageObjects::Pages::ChatChannel.new }
+  let(:thread) { PageObjects::Pages::ChatThread.new }
   let(:message_1) { thread_1.chat_messages.first }
 
   before do
@@ -24,12 +25,13 @@ RSpec.describe "Chat message - channel", type: :system, js: true do
 
   context "when hovering a message" do
     it "adds an active class" do
+      last_message = thread_1.chat_messages.last
       chat.visit_thread(thread_1)
 
-      channel.hover_message(message_1)
+      thread.hover_message(last_message)
 
       expect(page).to have_css(
-        ".chat-thread[data-id='#{thread_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
+        ".chat-thread[data-id='#{thread_1.id}'] [data-id='#{last_message.id}'] .chat-message.is-active",
       )
     end
   end
diff --git a/plugins/chat/spec/system/drawer_spec.rb b/plugins/chat/spec/system/drawer_spec.rb
index 125b1cfdc42..e85004efa27 100644
--- a/plugins/chat/spec/system/drawer_spec.rb
+++ b/plugins/chat/spec/system/drawer_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe "Drawer", type: :system, js: true do
       channel_page.hover_message(message_1)
       expect(page).to have_css(".chat-message-actions-container")
 
-      find(".chat-composer-input").send_keys(:escape)
+      find(".chat-composer__input").send_keys(:escape)
 
       expect(page).to have_no_css(".chat-message-actions-container")
     end
diff --git a/plugins/chat/spec/system/message_thread_indicator_spec.rb b/plugins/chat/spec/system/message_thread_indicator_spec.rb
index 8135a1023fa..d1985046012 100644
--- a/plugins/chat/spec/system/message_thread_indicator_spec.rb
+++ b/plugins/chat/spec/system/message_thread_indicator_spec.rb
@@ -88,8 +88,15 @@ describe "Thread indicator for chat messages", type: :system, js: true do
       channel_page.reply_to(message_without_thread)
       channel_page.fill_composer("this is a reply to make a new thread")
       channel_page.click_send_message
+
       expect(channel_page).to have_thread_indicator(message_without_thread)
-      new_thread = message_without_thread.reload.thread
+
+      new_thread = nil
+      try_until_success(timeout: 5) do
+        new_thread = message_without_thread.reload.thread
+        expect(new_thread).to be_present
+      end
+
       expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first))
     end
 
diff --git a/plugins/chat/spec/system/page_objects/chat/chat.rb b/plugins/chat/spec/system/page_objects/chat/chat.rb
index 0e80ff632d1..8079a10f60b 100644
--- a/plugins/chat/spec/system/page_objects/chat/chat.rb
+++ b/plugins/chat/spec/system/page_objects/chat/chat.rb
@@ -19,7 +19,7 @@ module PageObjects
 
       def visit_channel(channel, mobile: false)
         visit(channel.url + (mobile ? "?mobile_view=1" : ""))
-        has_no_css?(".not-loaded-once")
+        has_no_css?(".chat-channel--not-loaded-once")
         has_no_css?(".chat-skeleton")
       end
 
diff --git a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb
index f2e193ade82..6975f561410 100644
--- a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb
+++ b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb
@@ -4,25 +4,25 @@ module PageObjects
   module Pages
     class ChatChannel < PageObjects::Pages::Base
       def type_in_composer(input)
-        find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost
-        find(".chat-composer-input--channel").send_keys(input)
+        find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
+        find(".chat-channel .chat-composer__input").send_keys(input)
       end
 
       def fill_composer(input)
-        find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost
-        find(".chat-composer-input--channel").fill_in(with: input)
+        find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
+        find(".chat-channel .chat-composer__input").fill_in(with: input)
       end
 
       def click_composer
-        find(".chat-composer-input--channel").click # ensures autocomplete is closed and not masking anything
+        find(".chat-channel .chat-composer__input").click # ensures autocomplete is closed and not masking anything
       end
 
       def click_send_message
-        find(".chat-composer .send-btn:enabled").click
+        find(".chat-composer--send-enabled .chat-composer__send-btn").click
       end
 
       def message_by_id_selector(id)
-        ".chat-live-pane .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
+        ".chat-channel .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
       end
 
       def message_by_id(id)
diff --git a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb
index cf857d805fb..c76e4d68945 100644
--- a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb
+++ b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb
@@ -24,17 +24,17 @@ module PageObjects
       end
 
       def type_in_composer(input)
-        find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost
-        find(".chat-composer-input--thread").send_keys(input)
+        find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
+        find(".chat-thread .chat-composer__input").send_keys(input)
       end
 
       def fill_composer(input)
-        find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost
-        find(".chat-composer-input--thread").fill_in(with: input)
+        find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
+        find(".chat-thread .chat-composer__input").fill_in(with: input)
       end
 
       def click_composer
-        find(".chat-composer-input--thread").click # ensures autocomplete is closed and not masking anything
+        find(".chat-thread .chat-composer__input").click # ensures autocomplete is closed and not masking anything
       end
 
       def send_message(id, text = nil)
@@ -45,7 +45,9 @@ module PageObjects
       end
 
       def click_send_message(id)
-        find(thread_selector_by_id(id)).find(".chat-composer .send-btn:enabled").click
+        find(thread_selector_by_id(id)).find(
+          ".chat-composer--send-enabled .chat-composer__send-btn",
+        ).click
       end
 
       def has_message?(thread_id, text: nil, id: nil)
@@ -73,6 +75,18 @@ module PageObjects
           )
         end
       end
+
+      def hover_message(message)
+        message_by_id(message.id).hover
+      end
+
+      def message_by_id(id)
+        find(message_by_id_selector(id))
+      end
+
+      def message_by_id_selector(id)
+        ".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
+      end
     end
   end
 end
diff --git a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb
index 64f5816abbc..a6befde3676 100644
--- a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb
+++ b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb
@@ -36,7 +36,7 @@ module PageObjects
       end
 
       def has_open_channel?(channel)
-        has_css?("#{VISIBLE_DRAWER} .chat-live-pane[data-id='#{channel.id}']")
+        has_css?("#{VISIBLE_DRAWER} .chat-channel[data-id='#{channel.id}']")
       end
     end
   end
diff --git a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb
index 07084cdddf4..15f3ffd003e 100644
--- a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb
+++ b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
     it "adds bold text" do
       chat.visit_channel(channel_1)
 
-      composer = find(".chat-composer-input")
+      composer = find(".chat-composer__input")
       composer.send_keys([key_modifier, "b"])
 
       expect(composer.value).to eq("**strong text**")
@@ -34,7 +34,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
     it "adds italic text" do
       chat.visit_channel(channel_1)
 
-      composer = find(".chat-composer-input")
+      composer = find(".chat-composer__input")
       composer.send_keys([key_modifier, "i"])
 
       expect(composer.value).to eq("_emphasized text_")
@@ -45,7 +45,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
     it "adds preformatted text" do
       chat.visit_channel(channel_1)
 
-      composer = find(".chat-composer-input")
+      composer = find(".chat-composer__input")
       composer.send_keys([key_modifier, "e"])
 
       expect(composer.value).to eq("`indent preformatted text by 4 spaces`")
@@ -71,8 +71,8 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
       chat.visit_channel(channel_1)
       channel_page.message_thread_indicator(thread.original_message).click
 
-      composer = find(".chat-composer-input--channel")
-      thread_composer = find(".chat-composer-input--thread")
+      composer = find(".chat-channel .chat-composer__input")
+      thread_composer = find(".chat-thread .chat-composer__input")
       composer.send_keys([key_modifier, "i"])
 
       expect(composer.value).to eq("_emphasized text_")
@@ -98,7 +98,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
       chat.visit_channel(channel_1)
       expect(channel_page).to have_message(id: message_1.id)
 
-      find(".chat-composer-input").send_keys(:arrow_up)
+      find(".chat-composer__input").send_keys(:arrow_up)
 
       expect(page.find(".chat-composer-message-details")).to have_content(message_1.message)
     end
@@ -111,7 +111,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
         page.driver.browser.network_conditions = { offline: true }
         channel_page.send_message("Hello world")
 
-        find(".chat-composer-input").send_keys(:arrow_up)
+        find(".chat-composer__input").send_keys(:arrow_up)
 
         expect(page).to have_no_css(".chat-composer-message-details")
       end
diff --git a/plugins/chat/spec/system/shortcuts/drawer_spec.rb b/plugins/chat/spec/system/shortcuts/drawer_spec.rb
index d9c3640df2b..6333afca295 100644
--- a/plugins/chat/spec/system/shortcuts/drawer_spec.rb
+++ b/plugins/chat/spec/system/shortcuts/drawer_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
         expect(page).to have_css(".chat-drawer.is-expanded")
 
         drawer.open_channel(channel_1)
-        find(".chat-composer-input").send_keys(:escape)
+        find(".chat-composer__input").send_keys(:escape)
 
         expect(page).to have_no_css(".chat-drawer.is-expanded")
       end
@@ -49,7 +49,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
 
         page.send_keys("e")
 
-        expect(find(".chat-composer-input").value).to eq("")
+        expect(find(".chat-composer__input").value).to eq("")
       end
     end
 
@@ -59,15 +59,15 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
 
         expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]")
 
-        find(".chat-composer-input").send_keys(%i[alt arrow_down])
+        find(".chat-composer__input").send_keys(%i[alt arrow_down])
 
         expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]")
 
-        find(".chat-composer-input").send_keys(%i[alt arrow_down])
+        find(".chat-composer__input").send_keys(%i[alt arrow_down])
 
         expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]")
 
-        find(".chat-composer-input").send_keys(%i[alt arrow_up])
+        find(".chat-composer__input").send_keys(%i[alt arrow_up])
 
         expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]")
       end
diff --git a/plugins/chat/spec/system/shortcuts/full_page_spec.rb b/plugins/chat/spec/system/shortcuts/full_page_spec.rb
index 84478929be6..6b833b11dae 100644
--- a/plugins/chat/spec/system/shortcuts/full_page_spec.rb
+++ b/plugins/chat/spec/system/shortcuts/full_page_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe "Shortcuts | full page", type: :system, js: true do
 
       page.send_keys("e")
 
-      expect(find(".chat-composer-input").value).to eq("e")
+      expect(find(".chat-composer__input").value).to eq("e")
     end
   end
 end
diff --git a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb
index 869ef68f2e0..1be4ec80d91 100644
--- a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb
+++ b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb
@@ -182,7 +182,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do
         visit("/")
 
         expect(sidebar_page.dms_section.find(".channel-#{dm_channel_1.id}")["title"]).to eq(
-          "Chat with @&lt;script&gt;alert(&#x27;hello&#x27;)&lt;/script&gt;",
+          "Chat in @&lt;script&gt;alert(&#x27;hello&#x27;)&lt;/script&gt;",
         )
       end
     end
diff --git a/plugins/chat/spec/system/silenced_user_spec.rb b/plugins/chat/spec/system/silenced_user_spec.rb
index 50a73ee1497..5c845134d59 100644
--- a/plugins/chat/spec/system/silenced_user_spec.rb
+++ b/plugins/chat/spec/system/silenced_user_spec.rb
@@ -27,10 +27,10 @@ RSpec.describe "Silenced user", type: :system, js: true do
       )
     end
 
-    it "removes the send button" do
+    it "disables the send button" do
       chat.visit_channel(channel_1)
 
-      expect(page).to have_css(".send-btn[disabled]")
+      expect(page).to have_css(".chat-composer__send-btn[disabled]")
     end
 
     it "prevents reactions" do
diff --git a/plugins/chat/spec/system/uploads_spec.rb b/plugins/chat/spec/system/uploads_spec.rb
index 7000e8e178c..ab9339efbea 100644
--- a/plugins/chat/spec/system/uploads_spec.rb
+++ b/plugins/chat/spec/system/uploads_spec.rb
@@ -25,7 +25,6 @@ describe "Uploading files in chat messages", type: :system, js: true do
       end
 
       expect(page).to have_css(".chat-composer-upload .preview .preview-img")
-      expect(page).to have_content(File.basename(file_path))
 
       channel.send_message("upload testing")
 
@@ -61,7 +60,6 @@ describe "Uploading files in chat messages", type: :system, js: true do
         channel.click_action_button("chat-upload-btn")
       end
 
-      expect(page).to have_content(File.basename(file_path))
       expect(find(".chat-composer-upload")).to have_content("Processing")
 
       # image processing clientside is slow! here we are waiting for processing
@@ -101,10 +99,11 @@ describe "Uploading files in chat messages", type: :system, js: true do
     it "allows deleting uploads" do
       chat.visit_channel(channel_1)
       channel.open_edit_message(message_2)
-      find(".chat-composer-upload").find(".remove-upload").click
+      find(".chat-composer-upload").hover
+      find(".chat-composer-upload__remove-btn").click
       channel.click_send_message
       expect(channel.message_by_id(message_2.id)).not_to have_css(".chat-uploads")
-      expect(message_2.reload.uploads).to be_empty
+      try_until_success(timeout: 5) { expect(message_2.reload.uploads).to be_empty }
     end
 
     it "allows adding more uploads" do
@@ -118,13 +117,13 @@ describe "Uploading files in chat messages", type: :system, js: true do
       end
 
       expect(page).to have_css(".chat-composer-upload .preview .preview-img", count: 2)
-      expect(page).to have_content(File.basename(file_path))
 
       channel.click_send_message
 
       expect(page).not_to have_css(".chat-composer-upload")
       expect(page).to have_css(".chat-img-upload", count: 2)
-      expect(message_2.reload.uploads.count).to eq(2)
+
+      try_until_success(timeout: 5) { expect(message_2.reload.uploads.count).to eq(2) }
     end
   end
 
diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
index b842f618814..26f06d481f7 100644
--- a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
+++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
@@ -58,12 +58,12 @@ acceptance("Discourse Chat - Composer", function (needs) {
     };
 
     document
-      .querySelector(".chat-composer-input")
+      .querySelector(".chat-composer__input")
       .dispatchEvent(clipboardEvent);
 
     await settled();
 
-    assert.equal(document.querySelector(".chat-composer-input").value, "Foo");
+    assert.equal(document.querySelector(".chat-composer__input").value, "Foo");
   });
 });
 
@@ -97,7 +97,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
 
   skip("Sending a message with unreliable network", async function (assert) {
     await visit("/chat/c/-/11");
-    await fillIn(".chat-composer-input", "network-error-message");
+    await fillIn(".chat-composer__input", "network-error-message");
     await click(".send-btn");
 
     assert.ok(
@@ -105,7 +105,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
       "it adds a retry button"
     );
 
-    await fillIn(".chat-composer-input", "network-error-message");
+    await fillIn(".chat-composer__input", "network-error-message");
     await click(".send-btn");
     await publishToMessageBus(`/chat/11`, {
       type: "sent",
@@ -126,7 +126,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
       "it sends the message"
     );
     assert.strictEqual(
-      query(".chat-composer-input").value,
+      query(".chat-composer__input").value,
       "",
       "it clears the input"
     );
diff --git a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js
index 718b40e4a9c..eb9342fb608 100644
--- a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js
+++ b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js
@@ -15,20 +15,17 @@ module(
       pretender.get("/chat/emojis.json", () => [200, [], {}]);
 
       this.currentUser.set("id", 1);
-      this.set(
-        "chatChannel",
-        ChatChannel.create({
-          chatable_type: "DirectMessage",
-          chatable: {
-            users: [{ id: 1 }],
-          },
-        })
-      );
+      this.channel = ChatChannel.create({
+        chatable_type: "DirectMessage",
+        chatable: {
+          users: [{ id: 1 }],
+        },
+      });
 
-      await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
+      await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
 
       assert.strictEqual(
-        query(".chat-composer-input").placeholder,
+        query(".chat-composer__input").placeholder,
         "Jot something down"
       );
     });
@@ -36,24 +33,21 @@ module(
     test("direct message to multiple folks shows their names", async function (assert) {
       pretender.get("/chat/emojis.json", () => [200, [], {}]);
 
-      this.set(
-        "chatChannel",
-        ChatChannel.create({
-          chatable_type: "DirectMessage",
-          chatable: {
-            users: [
-              { name: "Tomtom" },
-              { name: "Steaky" },
-              { username: "zorro" },
-            ],
-          },
-        })
-      );
+      this.channel = ChatChannel.create({
+        chatable_type: "DirectMessage",
+        chatable: {
+          users: [
+            { name: "Tomtom" },
+            { name: "Steaky" },
+            { username: "zorro" },
+          ],
+        },
+      });
 
-      await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
+      await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
 
       assert.strictEqual(
-        query(".chat-composer-input").placeholder,
+        query(".chat-composer__input").placeholder,
         "Chat with Tomtom, Steaky, @zorro"
       );
     });
@@ -61,19 +55,16 @@ module(
     test("message to channel shows send message to channel name", async function (assert) {
       pretender.get("/chat/emojis.json", () => [200, [], {}]);
 
-      this.set(
-        "chatChannel",
-        ChatChannel.create({
-          chatable_type: "Category",
-          title: "just-cats",
-        })
-      );
+      this.channel = ChatChannel.create({
+        chatable_type: "Category",
+        title: "just-cats",
+      });
 
-      await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
+      await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
 
       assert.strictEqual(
-        query(".chat-composer-input").placeholder,
-        "Chat with #just-cats"
+        query(".chat-composer__input").placeholder,
+        "Chat in #just-cats"
       );
     });
   }
diff --git a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js
index 7a5d3526b2a..f57820c9f70 100644
--- a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js
+++ b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js
@@ -86,8 +86,6 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
     );
 
     assert.true(exists("img.preview-img[src='/images/avatar.png']"));
-    assert.strictEqual(query(".file-name").innerText.trim(), "bar_image.png");
-    assert.strictEqual(query(".extension-pill").innerText.trim(), "png");
   });
 
   test("removing completed upload", async function (assert) {
@@ -106,7 +104,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
       hbs`<ChatComposerUpload @isDone={{true}} @upload={{this.upload}} @onCancel={{fn this.removeUpload this.upload}} />`
     );
 
-    await click(".remove-upload");
+    await click(".chat-composer-upload__remove-btn");
     assert.strictEqual(this.uploadRemoved, true);
   });
 
@@ -126,7 +124,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
       hbs`<ChatComposerUpload @upload={{this.upload}} @onCancel={{fn this.removeUpload this.upload}} />`
     );
 
-    await click(".remove-upload");
+    await click(".chat-composer-upload__remove-btn");
     assert.strictEqual(this.uploadRemoved, true);
   });
 });
diff --git a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js
index 1a3872bd2c2..6d119715028 100644
--- a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js
+++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js
@@ -97,7 +97,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) {
 
     assert.dom(".chat-composer-upload").exists({ count: 1 });
 
-    await click(".remove-upload");
+    await click(".chat-composer-upload__remove-btn");
 
     assert.dom(".chat-composer-upload").exists({ count: 0 });
   });
@@ -138,7 +138,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) {
     await waitFor(".chat-composer-upload");
     assert.strictEqual(count(".chat-composer-upload"), 1);
 
-    await click(".remove-upload");
+    await click(".chat-composer-upload__remove-btn");
     assert.strictEqual(count(".chat-composer-upload"), 0);
   });
 });
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 666e83a87b7..f75359e5e00 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -296,7 +296,7 @@ RSpec.configure do |config|
       Selenium::WebDriver::Chrome::Options
         .new(logging_prefs: { "browser" => "INFO", "driver" => "ALL" })
         .tap do |options|
-          options.add_argument("--window-size=390,950")
+          options.add_argument("--window-size=390,960")
           options.add_argument("--no-sandbox")
           options.add_argument("--disable-dev-shm-usage")
           options.add_emulation(device_name: "iPhone 12 Pro")