Thread
A conversation: the container its messages, separators and typing indicator sit in, spacing them and, when it's live, giving a screen reader a log to follow. One family of message components renders a chat between people, a channel, an email thread and an AI chat.
A chat between people
Bubbles: theirs on the left with an avatar, yours on the right on the dark surface. continued: true joins a run from one sender; the last of yours carries its status; someone is typing.
- 👍1You reacted
<div class="mx-auto max-w-md">
<%= message_thread id: "thread_chat", live: true, label: "Chat with Ana Silva" do %>
<%= message_separator "Yesterday" %>
<%= message "Are we still on for Friday?", author: "Ana Silva", avatar: true, time: @now - 1.day, id: "thread_chat_1" %>
<%= message "I can do 2pm, or later if that's easier.", author: "Ana Silva", avatar: true, time: @now - 1.day + 1.minute, continued: true, id: "thread_chat_2" %>
<%= message "2pm is perfect. Same place?", own: true, time: @now - 1.day + 4.minutes, id: "thread_chat_3" %>
<%= message_separator "Today" %>
<%= message "Yes! I'll book the corner table.\nBring the sketches if you have them.", author: "Ana Silva", avatar: true, time: @now - 20.minutes, id: "thread_chat_4" do |m| %>
<% m.reactions { |r| r.reaction "👍", count: 1, reacted: true } %>
<% end %>
<%= message "Will do 👋", own: true, time: @now - 2.minutes, id: "thread_chat_5" do |m| %>
<% m.status :read, at: @now - 1.minute %>
<% end %>
<%= message_typing "Ana Silva", avatar: true %>
<% end %>
</div>A channel thread
Rows with avatars, names and times. Hover a row for its actions; a continued row shows its time in the gutter. Reactions, a quoted reply, a reply count and an unread marker.
Release notes are drafted in notes/launch.md. Can someone review before Friday?
Especially the migration section, it moved around a lot.
Ben ReyesCan someone review before Friday?
On it. I've left comments on the migration section and the rollback steps look right to me.
Checklist attached. Two items still open: the status page and the support macro.
<div class="mx-auto max-w-2xl">
<%= message_thread id: "thread_channel", live: true, label: "#launch" do %>
<%= message_separator time: @now.to_date - 1 %>
<%= message variant: :row, author: "Ben Reyes", avatar: true, time: @now - 26.hours, id: "thread_channel_1" do |m| %>
<% m.actions { |bar| bar.action "Reply", "#", icon: :reply; bar.action "Add reaction", "#", icon: :smile_plus; bar.copy "Release notes are drafted." } %>
<p>Release notes are drafted in <a href="#">notes/launch.md</a>. Can someone review before Friday?</p>
<% end %>
<%= message variant: :row, author: "Ben Reyes", avatar: true, time: @now - 26.hours + 2.minutes, continued: true, id: "thread_channel_2" do |m| %>
<% m.actions { |bar| bar.action "Reply", "#", icon: :reply; bar.copy "Especially the migration section." } %>
<% m.reactions { |r| r.reaction "👀", count: 2, names: [ "Ana", "Chloe" ], url: "#"; r.reaction "✅", count: 1, reacted: true, url: "#" } %>
<p>Especially the migration section, it moved around a lot.</p>
<% end %>
<%= message_separator unread: true %>
<%= message variant: :row, author: "Ana Silva", avatar: true, time: @now - 40.minutes, edited: true, id: "thread_channel_3" do |m| %>
<% m.quote "Can someone review before Friday?", author: "Ben Reyes", href: "#thread_channel_1" %>
<% m.actions { |bar| bar.action "Reply", "#", icon: :reply; bar.action "Edit", "#", icon: :pencil; bar.action "Delete", "#", icon: :trash_2, method: :delete, confirm: "Delete this message?" } %>
<% m.footer { link_to "3 replies", "#", class: "font-medium text-neutral-700 hover:underline dark:text-neutral-300" } %>
<p>On it. I've left comments on the migration section and the rollback steps look right to me.</p>
<% end %>
<%= message variant: :row, author: "Chloe Park", avatar: true, time: @now - 3.minutes, id: "thread_channel_4" do |m| %>
<% m.attachments { |files| files.file "launch-checklist.pdf", size: 88_400, url: "#" } %>
<% m.actions { |bar| bar.action "Reply", "#", icon: :reply; bar.copy "Checklist attached." } %>
<p>Checklist attached. Two items still open: the status page and the support macro.</p>
<% end %>
<% end %>
</div>An email conversation
Letters in cards. Older messages are collapsed to their header and a line of the body, and open on a click or Enter. Reply and Forward are text buttons through bar.control.
Re: Interview on Friday
Hi Keith,
Thanks for the invitation. Friday works for me. Would 2pm suit, and should I prepare anything beforehand?
Ana
2pm is great. Nothing to prepare: it's a conversation about the work you've enjoyed most, and we'll walk through the team's current project together.
Keith
<div class="mx-auto max-w-2xl">
<h3 class="mb-4 text-lg font-semibold">Re: Interview on Friday</h3>
<%= message_thread id: "thread_email" do %>
<%= message variant: :email, author: "Ana Silva", avatar: true, time: @now - 3.days, collapsible: true, open: false, id: "thread_email_1" do |m| %>
<% m.meta "to Keith Pitt" %>
<p>Hi Keith,</p>
<p>Thanks for the invitation. Friday works for me. Would 2pm suit, and should I prepare anything beforehand?</p>
<p>Ana</p>
<% end %>
<%= message variant: :email, author: "Keith Pitt", avatar: true, time: @now - 2.days, collapsible: true, open: false, id: "thread_email_2" do |m| %>
<% m.meta "to Ana Silva" %>
<p>2pm is great. Nothing to prepare: it's a conversation about the work you've enjoyed most, and we'll walk through the team's current project together.</p>
<p>Keith</p>
<% end %>
<%= message variant: :email, author: "Ana Silva", avatar: true, time: @now - 2.hours, collapsible: true, id: "thread_email_3" do |m| %>
<% m.meta "to Keith Pitt, cc Ben Reyes" %>
<% m.attachments { |files| files.file "portfolio-2026.pdf", size: 4_820_000, url: "#" } %>
<% m.actions(reveal: :always) do |bar| %>
<% bar.control { link_to "Reply", "#", class: button_classes(size: :small) } %>
<% bar.control { link_to "Forward", "#", class: button_classes(size: :small) } %>
<% end %>
<p>Perfect, see you Friday. I've attached the portfolio in case it's useful, and copied Ben who asked for it last week.</p>
<p>Ana</p>
<% end %>
<% end %>
</div>An AI chat
A bubble of your own and an unbubbled row for the reply, from the same helper. ai_chat_message is this with streaming, a thinking spinner and the optimistic template on top.
Three candidates haven't replied since Monday:
- Ana Silva opened hers twice but hasn't answered.
- Grace Okafor hasn't opened hers.
- Tom Reyes's bounced.
I'd nudge Grace today and leave Ana until after she's back.
<div class="mx-auto max-w-2xl">
<%= message_thread id: "thread_assistant", live: true, label: "Conversation" do %>
<%= message "Who hasn't replied to the Platform Engineer invitations yet?", own: true, id: "thread_assistant_q" %>
<%= message variant: :row, id: "thread_assistant_a" do |m| %>
<% m.actions { |bar| bar.copy "Three candidates haven't replied since Monday."; bar.action "Try again", "#", icon: :rotate_cw, method: :post } %>
<p>Three candidates haven't replied since Monday:</p>
<ul>
<li><strong>Ana Silva</strong> opened hers twice but hasn't answered.</li>
<li><strong>Grace Okafor</strong> hasn't opened hers.</li>
<li><strong>Tom Reyes</strong>'s bounced.</li>
</ul>
<p>I'd nudge Grace today and leave Ana until after she's back.</p>
<% end %>
<%= message "Draft the nudge for Grace.", own: true, id: "thread_assistant_q2" %>
<%= message_typing %>
<% end %>
</div>