MessageRole

MessageRole is which side of a conversation a turn came from, and the marker that side shows. It is not the turn itself: it owns no body, header, timestamp, or footer. The Slint authority, message-role.slint, declares one thing — export enum MessageRole { user, assistant } — and is shared by Message and Bubble rather than drawn, so every value the role decides is decided in the two components that consume it. This port draws what those two derive from the role alone, and nothing else:

Install
  • The marker a turn shows. message.slint gives both sides an avatar at AvatarSize.medium; a sent turn fills it with primary, takes primary-foreground initials, and borders it with the page background, while a received turn fills it with muted and takes muted-foreground. A sent turn that failed swaps both to destructive.
  • The side it sits on. Role::User is the sent turn at the trailing edge; Role::Assistant is the received turn at the leading edge.
  • The initials it falls back to. An empty caller string reads as "YO" for a sent turn and "AL" for a received one.

Two things are reproduced from the authority rather than corrected, because it is the authority: a failed sent turn’s initials are drawn in destructive — the fill itself, not destructive-foreground — and a received turn stays muted whatever failed says, since message.slint applies its tint only to the sent side. The marker is a fixed 32-pixel circle, the AvatarSize.medium both Slint consumers pick, with the avatar’s one-pixel border and no shadow.

Because a role is a value, the marker cannot place itself. Role::alignment() returns the edge the caller puts it at, and the application composes its own Message row around that; the marker itself has no alignment property.

GPUI has no separate MessageRole component, so this page shows a Slint and iced view only. A GPUI Message is placed with a MessageAlignment, and its accessibility role is announced through Message::role(...); the sent/received marker described here belongs to the Message and Bubble surfaces.

#Import

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The two roles, each with the initials message.slint falls back to when the
// caller names none: "YO" for the sent side, "AL" for the received one.
let usage = row![
    MessageRole::new(Role::User),
    MessageRole::new(Role::Assistant),
]
.spacing(theme::SPACING_SM);

The marker is built from the role. MessageRole defaults Role::Assistant, which is what message.slint defaults both Message and Bubble to, so a received turn needs nothing but the role. The initials drawn are the role’s fallback when the caller names none.

The marker is a presentational element. It does not own the message body, the sender, a timestamp, a delivery footer, or any action; compose those around it with the existing Message, Bubble, and Button components.

#Custom Initials

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The caller's own initials replace the fallback; the role still picks the
// fill and the colour the initials are drawn in.
let custom = row![
    MessageRole::new(Role::User).initials("MS"),
    MessageRole::new(Role::Assistant).initials("ST"),
]
.spacing(theme::SPACING_SM);

The caller’s own initials replace the fallback. The role still chooses the fill and the colour the initials are drawn in, so a two-party transcript stays legible with a real name behind each side.

initials(...) accepts any string. The empty string is read the same way message.slint reads it — as no name at all — so "".into() shows the role’s fallback rather than an empty disc. shown_initials() returns what is actually drawn.

#Failed State

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The failed state. It tints the sent turn destructive and leaves the
// received one muted, which is what message.slint does with its `failed`
// flag. The button flips the sent marker.
let failed = column![
    row![
        MessageRole::new(Role::User).failed(app.failed),
        MessageRole::new(Role::Assistant).failed(true),
    ]
    .spacing(theme::SPACING_SM),
    button(text(if app.failed {
        "Sent turn: failed"
    } else {
        "Sent turn: delivered"
    }))
    .on_press(Message::ToggleFailed),
]
.spacing(theme::SPACING_SM);

A sent turn that failed to send is tinted destructively, which is the one state the marker itself reports. A received turn is muted whatever failed says, because message.slint uses its tint only on the sent side.

The destructive tint is a colour, not an announcement. Keep the failure in text or another non-colour cue as well, and offer a retry action in the surrounding Message row.

#Alignment

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The side each role sits on, which is what Role::alignment carries: the
// received turn at the leading edge, the sent one at the trailing edge.
let alignment = column![
    row![
        text("received").size(theme::FONT_SIZE_XS),
        container(MessageRole::new(Role::Assistant))
            .width(Length::Fill)
            .align_x(Role::Assistant.alignment()),
    ]
    .spacing(theme::SPACING_SM)
    .align_y(Alignment::Center),
    row![
        container(MessageRole::new(Role::User))
            .width(Length::Fill)
            .align_x(Role::User.alignment()),
        text("sent").size(theme::FONT_SIZE_XS),
    ]
    .spacing(theme::SPACING_SM)
    .align_y(Alignment::Center),
]
.spacing(theme::SPACING_MD);

A marker cannot place itself, so the side each role sits on is a value the application reads and applies. Role::alignment() returns Alignment::End for a sent turn and Alignment::Start for a received one, and the marker is placed inside a container aligned to that edge.

Role::is_sent() answers the same question as a boolean for layout decisions, and Role::default_initials() returns the fallback without constructing a marker.

#API reference

#MessageRole

MethodDefaultPurpose
new(role)Role::AssistantCreate the marker for a conversation side.
initials(initials)the role’s fallbackSet the initials to draw; the empty string keeps the fallback.
failed(bool)falseTint a sent turn destructively; no effect on a received turn.
shown_initials()the fallback or the caller’s stringRead the initials actually drawn.

MessageRole converts into an Element, so it can be placed directly in a layout.

#Role

MethodPurpose
is_sent()Whether this is the side that sent the turn.
alignment()End for a sent turn, Start for a received one.
default_initials()"YO" for a sent turn, "AL" for a received one.
fill(tokens, failed)The marker’s fill: primary, destructive when failed, or muted.
ink(tokens, failed)The initials’ colour: primary-foreground, or destructive when failed; muted-foreground on a received turn.
marker_border(tokens)The marker’s border, drawn in the page background.

#Style and the marker’s geometry

Style::new(role).failed(failed).resolve(tokens) returns the resolved container::Style the marker draws: the fill, the one-pixel border, and the edge / 2 circle.

ItemValueMeaning
MARKER_EDGE32.0The marker’s diameter in pixels, the AvatarSize.medium of every turn.
MARKER_BORDER_WIDTH1.0The avatar’s own border width.
  • Message — the row that owns sender identity, metadata, content, and footer, and places a role’s marker at its edge.
  • Bubble — the content surface the role also selects a variant for.
  • Avatar — the component message.slint takes the marker’s AvatarSize.medium geometry from.

#MessageRole

Shared by `Message` and `Bubble`. It lives in its own file because `Message` renders a `Bubble`, and a `MessageRole` declared in either one would make the two import each other.

user, assistant