Message
Message is the row-level composition primitive for a conversation. It owns
the horizontal alignment and the vertical stack that contains optional sender
identity, metadata, content, and footer slots. It does not own a sender model,
timestamp formatting, delivery state, reaction state, or message actions.
Applications provide those values and compose existing components inside the slots. This keeps the message layout reusable across direct messages, group chat, assistant responses, system notices, and generated content.
#Import
use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _};
use gpui_kit::component::{
ActiveTheme as _, Colorize as _, Sizable as _,
attachment::{Attachment, AttachmentContent, AttachmentTitle},
avatar::Avatar,
bubble::{Bubble, BubbleVariant},
button::{Button, ButtonVariants as _},
message::{
Message, MessageAlignment, MessageAvatar, MessageContent, MessageFooter,
MessageGroup, MessageHeader,
},
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { MessageAlignment, Message } from "message.slint";use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment as Axis, Color, Element, Length, Task, Theme};#Anatomy and basic usage
// The whole row in its shortest complete form: a sender line, the bubble,
// and a footer under both. A received turn is the default; the sent one
// names its role.
let anatomy = column![
Message::new()
.author("Alice")
.time("10:24 AM")
.initials("AL")
.body("Can you review this?")
.footer("Read"),
Message::new()
.role(Role::User)
.author("You")
.time("10:25 AM")
.initials("YO")
.body("Sure — I will send notes shortly.")
.footer("Delivered"),
]
.spacing(TURN_GAP)
.width(Length::Fill);All named slots are optional, so a minimal message can contain only a body:
Message::new().content(
MessageContent::new().bubble(Bubble::new().child("Can you review this?")),
)VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.assistant;
author: "Alice";
time: "10:24 AM";
initials: "AL";
footer: "Read";
}
Message {
role: MessageRole.user;
author: "You";
time: "10:25 AM";
initials: "YO";
body: "Sure — I will send notes shortly.";
footer: "Delivered";
}
}A complete message commonly combines sender identity, metadata, a bubble, and a delivery footer:
Message::new()
.avatar_slot(
MessageAvatar::new()
.child(Avatar::new().name("Alice").size_8()),
)
.header(
MessageHeader::new()
.child("Alice")
.child("10:24 AM"),
)
.content(
MessageContent::new().bubble(
Bubble::new()
.with_variant(BubbleVariant::Secondary)
.child("Can you review this draft?"),
),
)
.footer(MessageFooter::new().child("Read"))The default state is:
| Property | Default | Meaning |
|---|---|---|
| Alignment | MessageAlignment::Start | Place the message at the leading edge. |
| Avatar/header/content/footer | absent | Add only the slots needed by the product. |
| Outer layout | full width, min_w_0(), gap_2() | Keeps rows usable in a virtual list. |
| Inner stack gap | rems(0.625) | Separates metadata, body, and footer. |
| Header/footer inset | enabled, px_3() | Aligns metadata with a regular bubble surface. |
| Typography | inherited | The header and footer set their own; content typography belongs to the bubble or the caller. |
| Identity and role | none, presentational | id(...) gives the row a stable identity; role(...) announces it. |
| Avatar baseline | min_w_8(), circular muted surface | Gives sender identity a stable column. |
Message applies its alignment to the complete row and to the named content
stack. It reverses the outer row for End, so the avatar and message stack
remain a single aligned unit.
#Alignment
// `user` turns sit at the trailing edge and take the primary surface;
// everything else starts at the leading edge on the secondary one. The role
// picks the side, and `alignment` can override it.
let alignment = column![
Message::new()
.author("Support")
.time("10:42 AM")
.initials("ST")
.body("Your issue has been assigned to the team."),
Message::new()
.role(Role::User)
.author("You")
.time("Just now")
.initials("YO")
.body("Thank you for the quick update.")
.footer("Delivered"),
]
.spacing(TURN_GAP)
.width(Length::Fill);Use Start for incoming content and End for outgoing content:
Message::new()
.alignment(MessageAlignment::Start)
.avatar(Avatar::new().name("Alice").size_8())
.header(MessageHeader::new().child("Alice").child("10:24 AM"))
.content(MessageContent::new().bubble(
Bubble::new()
.with_variant(BubbleVariant::Secondary)
.child("Incoming message"),
));
Message::new()
.alignment(MessageAlignment::End)
.avatar(Avatar::new().name("You").size_8())
.header(MessageHeader::new().child("You").child("10:25 AM"))
.content(MessageContent::new().bubble(Bubble::new().child("Outgoing message")))
.footer(MessageFooter::new().child("Delivered"))VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.assistant;
author: "Support";
time: "10:42 AM";
initials: "ST";
}
Message {
role: MessageRole.user;
author: "You";
time: "Just now";
initials: "YO";
body: "Thank you for the quick update.";
footer: "Delivered";
}
}MessageAlignment is also accepted by Bubble. When the body is a typed
MessageContent::bubble(...), the message propagates its alignment to that
bubble’s surface. Leave the bubble’s own alignment unset in this composition so
the row has one clear owner of placement.
#Avatar, header, content, and footer
#Avatar
// Initials stand in whenever there is no image. A caller passes its own
// `Avatar` through the slot, and a turn with no identity column pulls its
// text to the edge instead.
let avatar = column![
Message::new()
.author("Alice Chen")
.initials("AC")
.body("Initials stand in whenever there is no image."),
Message::new()
.author("Jordan Park")
.initials("JP")
.body("A turn with no avatar slot pulls its text to the edge instead.")
.show_avatar(false),
Message::new()
.author("Casey Lee")
.avatar(Avatar::new().initials("CL"))
.body("The caller's own Avatar can fill the slot."),
]
.spacing(TURN_GAP)
.width(Length::Fill);.avatar(...) wraps any element in MessageAvatar. Use .avatar_slot(...)
when the slot itself needs styling or multiple children:
Message::new()
.avatar_slot(
MessageAvatar::new()
.bg(cx.theme().transparent)
.child(Avatar::new().name("System").size_8()),
)
.content(MessageContent::new().child("A system update"))VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.assistant;
author: "Alice Chen";
initials: "AC";
}
Message {
role: MessageRole.assistant;
author: "Jordan Park";
initials: "JP";
body: "A turn with no avatar slot pulls its text to the edge instead.";
show-avatar: false;
}
}The avatar reserves the shared size-8 baseline and always sits flush with
the bottom edge of the message content; the footer renders below the avatar
row, indented to the content column. The message does not require an avatar;
omit it for assistant messages, compact group chat, or system rows where
identity is already present elsewhere.
#Header
// The header carries the sender and the timestamp, and lines up with the
// bubble rather than with the avatar.
let header = column![
Message::new()
.author("Alice Chen")
.time("10:24 AM")
.initials("AC")
.body("The header carries the sender and the timestamp."),
Message::new()
.role(Role::User)
.author("You")
.time("10:25 AM")
.initials("YO")
.body("And it lines up with the bubble, not with the avatar."),
]
.spacing(TURN_GAP)
.width(Length::Fill);MessageHeader is an arbitrary horizontal metadata row. It defaults to an
extra-small, medium-weight, muted style with a px_3() inset:
MessageHeader::new()
.child("Alice")
.child("·")
.child("10:24 AM")VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.assistant;
author: "Alice Chen";
time: "10:24 AM";
initials: "AC";
}
Message {
role: MessageRole.user;
author: "You";
time: "10:25 AM";
initials: "YO";
body: "And it lines up with the bubble, not with the avatar.";
}
}The header does not format dates or infer sender names. Use application-owned
formatting and compose a Tooltip around timestamps when the full date is
useful.
#Content
// The body is the bubble's text; `child` composes arbitrary rich content
// into the same surface, and the application colours what it adds.
let content = column![
Message::new()
.author("Alice")
.initials("AL")
.body("The export is ready.")
.child(
row![
line("export.zip", theme::FONT_SIZE_SM, |tokens| tokens
.foreground),
line("2.4 MB · ZIP", theme::FONT_SIZE_XS, |tokens| {
tokens.muted_foreground
}),
]
.spacing(theme::SPACING_SM)
.align_y(Axis::Center),
)
.footer("Read"),
Message::new()
.role(Role::User)
.author("You")
.initials("YO")
.body("Send it over.")
.footer("Delivered"),
]
.spacing(TURN_GAP)
.width(Length::Fill);MessageContent is a full-width, minimum-width-safe vertical stack. It accepts
arbitrary elements and has a typed .bubble(...) convenience that records
whether a ghost bubble is present:
MessageContent::new()
.bubble(Bubble::new().child("First paragraph"))
.bubble(Bubble::new().child("Second paragraph"))Use .child(...) for attachments, code blocks, images, or custom rich content:
MessageContent::new()
.bubble(Bubble::new().child("Here is the file:"))
.child(
Attachment::new().content(
AttachmentContent::new()
.title(AttachmentTitle::new("quarterly-report.pdf")),
),
)Typed bubbles are useful when the surrounding header and footer should respond
to the Ghost variant. Arbitrary .child(...) values are still fully
composable, but their concrete type is erased and they do not set that
ghost-surface metadata.
#Footer
// A footer states delivery; a failed turn says so and draws its `Retry` once
// the caller supplies the message it reports.
let footer = column![
Message::new()
.role(Role::User)
.initials("YO")
.body("This one was delivered.")
.footer("Delivered"),
Message::new()
.role(Role::User)
.initials("YO")
.body("The response could not be sent.")
.failed(true)
.on_retry(Event::Retry),
]
.spacing(TURN_GAP)
.width(Length::Fill);MessageFooter is another arbitrary horizontal metadata row. Use it for
delivery state, reactions, or actions composed from existing controls:
MessageFooter::new()
.child("Delivered")
.child(Button::new("reply").ghost().xsmall().label("Reply"))
.child(Button::new("copy").ghost().xsmall().label("Copy"))VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.user;
initials: "YO";
footer: "Delivered";
}
Message {
role: MessageRole.user;
initials: "YO";
body: "The response could not be sent.";
failed: true;
}
}Footer uses the same extra-small muted default and px_3() inset as the header.
The footer does not own a delivery-state enum or action semantics.
#Rich content and actions
// The actions are the caller's: the message reserves the row, and each
// control carries its own message. A rich child sits in the bubble above it.
let rich = Message::new()
.author("Support")
.time("10:42 AM")
.initials("ST")
.body("The install failure is coming from the workspace package.")
.child(line(
"workspace-package@0.14.2",
theme::FONT_SIZE_XS,
|tokens| tokens.muted_foreground,
))
.action(
Button::new("Copy")
.variant(ButtonVariant::Ghost)
.size(ControlSize::XSmall)
.on_press(Event::Copy),
)
.action(
Button::new("Retry")
.variant(ButtonVariant::Ghost)
.size(ControlSize::XSmall)
.on_press(Event::Retry),
)
.footer("Read");Compose the existing component that owns each behavior:
Message::new()
.content(
MessageContent::new()
.bubble(Bubble::new().child("The export is ready."))
.child(
Attachment::new()
.content(AttachmentContent::new().title(AttachmentTitle::new("export.zip"))),
),
)
.footer(
MessageFooter::new()
.child(Button::new("download-export").label("Download"))
.child(Button::new("share-export").ghost().label("Share")),
)VerticalLayout {
spacing: 20px;
Message {
role: MessageRole.assistant;
author: "Support";
initials: "ST";
show-actions: true;
}
HorizontalLayout {
spacing: 8px;
alignment: start;
Button {
text: "Copy";
icon: "copy";
variant: ButtonVariant.ghost;
}
Button {
text: "Retry";
icon: "refresh-cw";
variant: ButtonVariant.ghost;
}
}
}Use Button for commands, Link for URLs, Attachment for files, and
Bubble for conversational surfaces. This keeps disabled, loading, focus,
keyboard, and accessible-name behavior on the control that owns it. A message
does not become clickable merely because it contains a button.
Long or multiline content remains the responsibility of the child element. Keep
custom children min_w_0() when they contain long text or horizontal layouts;
Message already applies w_full() and min_w_0() to its own row and stack.
#Grouping
// `Group` is a plain vertical stack: the application decides which turns
// belong to one sender and which edge the stack sits on.
let grouping = column![
Group::new()
.child(
Message::new()
.author("Alice")
.initials("AL")
.body("Can you tell me what changed?"),
)
.child(
Message::new()
.author("Alice")
.initials("AL")
.body("The registry route was stale."),
),
Group::new()
.alignment(Alignment::End)
.child(
Message::new()
.role(Role::User)
.author("You")
.initials("YO")
.body("I removed the stale route."),
)
.child(
Message::new()
.role(Role::User)
.author("You")
.initials("YO")
.body("The updated registry is ready to review."),
),
]
.spacing(theme::SPACING_XL)
.width(Length::Fill);MessageGroup is a styleable vertical stack for consecutive messages. It does
not decide which sender owns a message or automatically remove metadata:
MessageGroup::new()
.child(
Message::new()
.avatar(Avatar::new().name("Alice").size_8())
.header(MessageHeader::new().child("Alice"))
.content(MessageContent::new().bubble(
Bubble::new()
.with_variant(BubbleVariant::Secondary)
.child("The first message."),
)),
)
.child(
Message::new()
.avatar_slot(MessageAvatar::new().bg(cx.theme().transparent))
.content(MessageContent::new().bubble(
Bubble::new()
.with_variant(BubbleVariant::Secondary)
.child("The follow-up keeps the same sender context."),
)),
)Use BubbleGroup when only the bubbles are grouped and there is no message
header, avatar, or footer. Use MessageGroup when each item is a full row.
#Ghost surfaces and content insets
// A ghost turn has no surface to align with, so its metadata drops the
// regular inset; the caller can keep it or remove it explicitly.
let ghost = column![
Message::new()
.author("System")
.time("Just now")
.initials("SY")
.body("The conversation has been archived.")
.bubble_variant(BubbleVariant::Ghost)
.footer("No further action required"),
Message::new()
.author("System")
.initials("SY")
.body("This ghost turn keeps the inset with the regular surface.")
.bubble_variant(BubbleVariant::Ghost)
.content_inset(true),
Message::new()
.author("Alice")
.initials("AL")
.body("This regular turn removes the inset the caller owns.")
.content_inset(false),
]
.spacing(TURN_GAP)
.width(Length::Fill);The typed MessageContent::bubble(...) builder records a ghost bubble. In that
case, Message removes the default header and footer insets so metadata lines
up with the unframed content:
Message::new()
.header(MessageHeader::new().child("System").child("Just now"))
.content(MessageContent::new().bubble(
Bubble::new()
.with_variant(BubbleVariant::Ghost)
.child("The conversation has been archived."),
))
.footer(MessageFooter::new().child("No further action required"))Override this behavior explicitly on either named metadata slot:
MessageHeader::new()
.content_inset(true)
.child("Keep the regular header inset");
MessageFooter::new()
.content_inset(false)
.child("Align the footer with a custom surface")content_inset(...) takes precedence over inherited ghost behavior. A typed
ghost bubble is required for automatic inheritance; an arbitrary child that
happens to look like a ghost surface cannot be inspected by Message.
The inner slot stack can also be refined independently:
Message::new()
.with_stack_style(StyleRefinement::default().gap_3())
.content(MessageContent::new().child("A wider message rhythm"))#Custom styling and theme tokens
// The surface is the bubble's own `Content`, so the fill, ink and edge come
// from semantic tokens resolved inside the style closure — the same route
// the page documents for a caller-owned message surface.
let custom = Message::new()
.author("System")
.time("Just now")
.initials("SY")
.body("A message on a caller-owned surface.")
.bubble_content(
Content::new()
.fill(|tokens| theme::alpha(tokens.success, 0.15))
.text_color(|tokens| tokens.success)
.border_color(|tokens| theme::alpha(tokens.success, 0.35))
.border_width(1.0)
.radius(theme::RADIUS_LG),
)
.footer("Delivered");Message, MessageGroup, MessageAvatar, MessageHeader, MessageContent,
and MessageFooter implement Styled. Style the part that owns the visual
decision:
Message::new()
.p_3()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().muted.opacity(0.35))
.header(MessageHeader::new().px_0().child("System"))
.content(MessageContent::new().child("Archived"))
.footer(MessageFooter::new().px_0().child("Just now"))Use with_stack_style(...) for the vertical stack, slot refinements for
header/content/footer typography and spacing, and the child component’s own
API for bubble, attachment, or button surfaces. Message itself sets no text
size or line height, so Markdown or other rich content placed in a Ghost
bubble keeps the typography of its renderer. Radius, spacing, typography,
and colors should come from the active semantic theme or shared scale. Avoid
raw colors at message call sites so the same composition works in light and
dark themes.
#Accessibility and state guidance
- Keep sender identity and message content in readable text. An avatar alone should not be the only indication of who sent a message.
- A message is presentational by default. Give transcript rows
.id(...)and.role(Role::ListItem)so assistive technology can move between them; the role needs the stable identity an id provides. - Put commands in semantic
ButtonorLinkcontrols. For the currentButtonAPI, use a visible.label(...)when a footer action needs an accessible name; a tooltip is supplemental. - Delivery, failure, streaming, and unread states belong in text or semantic controls. Do not communicate them with alignment, color, or opacity alone.
- Preserve the header/footer inset when it is the visual relationship that aligns metadata with the surface. If a custom surface removes it, verify the reading order and keyboard order still match the visual order.
- Keep multiline content readable at the application’s minimum window width;
use
min_w_0()on nested horizontal content and avoid hover-only actions. - Motion for generated content belongs to
ShimmerTextor another motion-aware component. Reduced-motion behavior should leave the message text present and understandable.
#Component boundaries
The GPUI component intentionally does not add provider or domain layers:
Messageowns row alignment and slot layout.- The application owns sender records, timestamps, delivery state, reactions, permissions, message IDs, and persistence.
Bubble,Attachment,Button,Link, andMarkerown their own visual or behavioral primitives and are composed through message slots.MessageGrouponly supplies a vertical stack. It does not infer sender changes or collapse headers.
If a product needs a specific “assistant message” or “group chat message” with
fixed metadata policy, wrap Message in an application component. Keep that
domain policy out of the general-purpose primitive.
#API reference
#Message
| Method | Default | Purpose |
|---|---|---|
new() | Start, no slots | Create a message row. |
alignment(MessageAlignment) | Start | Set leading or trailing alignment. |
id(ElementId) | none | Give the row a stable identity for element state and the accessibility tree. |
role(Role) | presentational | Announce the row to assistive technology, e.g. Role::ListItem; requires id(...). |
with_stack_style(StyleRefinement) | component stack defaults | Refine the inner vertical stack. |
avatar(element) | none | Wrap an element in MessageAvatar. |
avatar_slot(MessageAvatar) | none | Set a fully configured avatar slot. |
header(MessageHeader) | none | Set sender and metadata content. |
content(MessageContent) | none | Set the message body. |
footer(MessageFooter) | none | Set delivery, reactions, or actions. |
Message also implements Styled for the outer row.
#MessageGroup
| Method | Default | Purpose |
|---|---|---|
new() | empty vertical stack | Create a message group. |
.child(element) | — | Add complete messages. |
Styled methods | gap_2() | Refine group spacing and layout. |
#MessageAvatar
| Method | Default | Purpose |
|---|---|---|
new() | empty circular size_8 baseline | Create an identity slot. |
.child(element) | — | Add Avatar or another identity element. |
Styled methods | muted surface and full radius | Refine size, background, and alignment. |
#MessageHeader and MessageFooter
| Method | Default | Purpose |
|---|---|---|
new() | empty extra-small metadata row | Create the slot. |
content_inset(bool) | inherited or true | Keep or remove the default px_3() inset. |
.child(element) | — | Add text, metadata, reactions, or controls. |
Styled methods | muted, medium-weight, text_xs() | Refine the slot. |
#MessageContent
| Method | Default | Purpose |
|---|---|---|
new() | empty full-width vertical stack | Create the body slot. |
bubble(Bubble) | — | Add a typed bubble and propagate ghost metadata. |
.child(element) | — | Add arbitrary rich content. |
Styled methods | min_w_0(), gap(rems(0.625)) | Refine body layout. |
#Related types
- [
MessageAlignment] —StartorEnd. - [
Bubble] — conversational surface content. - [
Attachment] — files and media. - [
MessageScroller] — virtualized conversation rows and tail following.
#MessageAlignment
start, end
#Message
One chat turn. Mirrors GPUI Component's `Message`. The row is a column: an avatar-and-content line, then the footer. The avatar is bottom-aligned with the content so it stays flush however tall the bubble grows, and it swaps sides with the alignment, exactly as GPUI's `flex_row_reverse` does for `MessageAlignment::End`.
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
role | in | MessageRole | MessageRole.assistant | |
alignment | in | MessageAlignment | role == MessageRole.user ? MessageAlignment.end : MessageAlignment.start | `user` turns are the sent ones and sit at the trailing edge by default. |
author | in | string | Sender name and timestamp, drawn above the bubble. | |
time | in | string | ||
body | in | string | ||
initials | in | string | ||
footer | in | string | Delivery or read status, drawn under the row. | |
show-avatar | in | bool | true | |
show-actions | in | bool | ||
failed | in | bool | A turn that failed to send reads as destructive and offers Retry. | |
bubble-variant | in | BubbleVariant | role == MessageRole.user ? BubbleVariant.filled : BubbleVariant.secondary | Which surface the body gets. GPUI passes the `Bubble` in, so the caller chooses; a conversation uses `muted`/`ghost`, a two-party transcript `filled`/`secondary`. |
copy-requested | callback | () | ||
retry-requested | callback | () |