Skip to content

Chat bubble

<aparte-chat-bubble>

One message: plain content or a list of rich segments, in light DOM.

Normally created for you by <aparte-chat-viewport>, one per message in the store; you write the tag by hand only when you drive the DOM yourself. It is ONE message with one role — a transcript is the viewport’s job, and a bubble is not a general-purpose card.

Not a slot host. _render() writes its own markup into the light DOM on connect, so children placed inside the tag are replaced rather than projected. Everything customizable is a registered hook instead of a child: the structural shell (setBubbleShellRenderer — it must root at .aparte-message and carry the region hooks, since every query here is null-guarded and a partial shell silently loses that region), the avatar (setAvatarProvider), the attachment chips (setAttachmentRenderer), the ‹1/2› position indicator (setSiblingNavRenderer) and the body itself (registerSegmentRenderer).

Two content paths, mutually exclusive: the content attribute (plain text run through the configured Markdown provider, then highlighted once — after streaming ends, not per token) and setSegments() / addSegment(). Segments win: .aparte-content stays hidden for as long as any exist. The painted .aparte-message-content box hides itself when there is nothing in it, so a message that is only attachments is not a coloured rectangle.

The bubble owns no transport and no host behaviour. The action bar and the branch picker only dispatch the events below; nothing here retries a turn, persists an edit, opens a stats popover or switches a branch. Which buttons exist follows from that: copy is on by default, edit / retry / feedback need setBubbleActions, info needs both that flag and a prior setUsage() (a details button over no numbers is a dead button), and an image attachment becomes a preview button only once setHostHandlers declares a lightbox — undeclared it stays a picture, with no role, tab stop or pointer.

The error state is derived from the segments (an error segment sets data-error on .aparte-message), never from a status attribute, so it behaves identically in vanilla and in every wrapper.

All seven events are declared by hand rather than left to the analyser, which found six. aparte-branch-navigate is dispatched from the _onBranchPickerClick arrow class field, and the auto-detection visits ts.isMethodDeclaration only — so the one event belonging to the branch picker was the one missing from the manifest, and from the generated reference, for as long as both existed.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- Rendered for you by the viewport. Written by hand only when you drive the DOM
yourself: `message-id` is what streaming and the action bar address it by. -->
<aparte-chat-bubble
message-id="a1"
data-role="assistant"
name="Assistant"
content="Hello."
></aparte-chat-bubble>
<!-- While a reply is in flight: `streaming` hides the action bar and shows the caret. -->
<aparte-chat-bubble message-id="a2" data-role="assistant" streaming></aparte-chat-bubble>
<!-- One reply among several. `setSiblings(count, index)` is what draws the picker, and
it is a METHOD, not an attribute — so a branch cannot be shown by markup alone.
Retry forks a sibling instead of overwriting the reply, and this is the control
that walks them; each press dispatches `aparte-branch-navigate` for a host to
answer. Kept in the example because a guide that describes branching has no other
way to SHOW it. -->
<aparte-chat-bubble
message-id="a3"
data-role="assistant"
name="Assistant"
content="A second take on the same question."
></aparte-chat-bubble>
<script>
document.querySelector('aparte-chat-bubble[message-id="a3"]').setSiblings(2, 0);
</script>
AttributeDescription
data-roleThe message role, user or assistant — the one channel for it, and what the CSS keys off. (role is ARIA’s attribute; this element sets it to article itself and no longer reads a message role from it.)
contentPlain text content, for a bubble with no segments.
timestampEpoch milliseconds OR a date string: _updateTimestamp accepts either and only coerces when the value is numeric.
message-idHow streaming and the action bar address this bubble.
streamingHides the action bar and shows the caret while a reply is in flight.
nameThe display name in the header.
MethodDescription
appendToken(chunk: string): voidAppend a token chunk (for streaming)
setContent(content: string): voidSet content directly
getContent(): stringGet current content
setSegments(segments: AparteSegment[]): voidSet segments for rich content
addSegment(segment: AparteSegment): voidAdd a segment
updateSegment(segmentId: string, updates: Partial&lt;AparteSegment>): voidUpdate a specific segment
appendToSegment(segmentId: string, content: string): voidAppend content to a segment
getSegments(): AparteSegment[]Get all segments
removeSegment(segmentId: string): voidScoped to DIRECT children on purpose. Segments are appended as direct children of the container, but a descendant query returns the first match in document order — and sanitized model markdown renders inside that same container, with data-* attributes deliberately preserved (they are inert). So a decoy data-segment-id planted in an earlier segment’s prose used to win over the real segment element. Parser ids are unguessable UUIDs, but a tool segment is tool-${toolCallId} and the MODEL chooses that id — so this was reachable, and pointing an update at a decoy left a rejected tool rendering as still-running: a spoof against the human-in-the-loop control.
setAttachments(attachments: AparteAttachment[]): voidSet attachments (chips shown above message content, user role only)
setUsage(usage: AparteUsage | null | undefined): voidSet token usage + timing for this message (assistant only). This is the precondition for the info (“i”) action, not the trigger: the button appears only if the app also declared it wants it — aparteGlobalConfig.setBubbleActions({ info: true }) — because the stats popover it opens (aparte-message-info) is the app’s, and core has none. Without usage there is nothing to show, so the button never renders either way.
setSiblings(count: number, index: number): voidUpdate the branch picker UI for tree-based navigation. The viewport calls this after a branch switch or re-render. Prev/Next clicks dispatch aparte-branch-navigate (bubbles: true) so the viewport can handle the actual tree switch.
updateMessage(updates: Partial&lt;AparteMessage>): voidAtomic update for the message
setTranscriptBusy(busy: boolean): void
EventTypeDescription
aparte-attachment-previewCustomEvent&lt;AparteAttachmentPreviewEventDetail>An attached image was clicked, asking the app to open it full-size.
aparte-actionCustomEvent&lt;AparteActionEventDetail>A custom action-bar button was pressed.
aparte-retryCustomEvent&lt;AparteRetryEventDetail>Retry was pressed; the host forks the turn.
aparte-feedbackCustomEvent&lt;AparteFeedbackEventDetail>Thumbs up or down.
aparte-message-infoCustomEvent&lt;AparteMessageInfoEventDetail>The info affordance was pressed.
aparte-editCustomEvent&lt;AparteEditEventDetail>An edit was saved.
aparte-branch-navigateCustomEvent&lt;AparteBranchNavigateEventDetail>The ‹1/2› picker moved between sibling versions.
aparte-link-clickCustomEvent&lt;AparteLinkClickEventDetail>A link in the message body is about to be followed. Cancelable: preventDefault() keeps the browser from navigating, so a host can route the link itself.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-message-gapvar(--aparte-space-6)Gap between the avatar column and the body (the viewport reuses it between messages).
--aparte-message-padding-blockvar(--aparte-space-8)Vertical padding of one message row. (The composite --aparte-message-padding is gone: joined on :root, it froze both parts, and the narrow-container override of them never reached the row.)
--aparte-message-padding-inlinevar(--aparte-space-6)Horizontal padding of one message row.
--aparte-message-max-width800pxWidth of the centred message row.
--aparte-message-content-radiusvar(--aparte-radius-bubble)Radius of the painted content box.
--aparte-message-content-paddingvar(--aparte-space-5) var(--aparte-space-7)Padding of the USER box only; the assistant’s content is plain full-width prose.
--aparte-message-content-bg-userBackground of the user box: a wash of --aparte-primary over --aparte-surface-1, derived in theme.css so a rebrand moves it (declare it to override).
--aparte-message-content-bg-assistanttransparentBackground of the assistant box — transparent on purpose (AI-chat convention, not messaging).
--aparte-message-content-text-uservar(--aparte-text)Text colour inside the user box.
--aparte-message-content-text-assistantvar(--aparte-text)Text colour inside the assistant box.
--aparte-avatar-size32pxSquare size of the avatar slot.
--aparte-avatar-radius-ratio0.28Avatar corner radius, as a fraction of its size (the corner follows the ramp; 0.28 × 32px is the 9px it used to be).
--aparte-avatar-initials-ratio0.34Size of the initial, as a fraction of the avatar’s size.
--aparte-avatar-bg-uservar(--aparte-primary)Avatar background, user role.
--aparte-avatar-text-uservar(--aparte-text-inverse)Avatar text colour, user role.
--aparte-avatar-bg-assistantvar(--aparte-surface-3)Avatar background, assistant role.
--aparte-avatar-text-assistantvar(--aparte-text)Avatar text colour, assistant role.
--aparte-avatar-image-usernonebackground-image for the user avatar — a logo with no AvatarProvider and no JS.
--aparte-avatar-image-assistantnonebackground-image for the assistant avatar.
--aparte-avatar-image-size90%background-size for both avatar images.
--aparte-name-font-sizevar(--aparte-font-size-md)Sender name in the header.
--aparte-name-colorvar(--aparte-text)Sender name colour.
--aparte-timestamp-font-sizevar(--aparte-font-size-sm)Timestamp in the header.
--aparte-timestamp-colorvar(--aparte-text-muted)Timestamp colour.
--aparte-content-font-sizevar(--aparte-font-size-base)Body type size, applied to both the plain-content and the segments container.
--aparte-content-colorvar(--aparte-text)Body text colour.
--aparte-content-line-heightvar(--aparte-line-height-loose)Body line height.
--aparte-attachments-max-height140pxCap on the sent-attachment strip; past it the strip scrolls instead of growing.
--aparte-attachment-image-size72pxTile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation.
--aparte-thumb-radiusvar(--aparte-radius-lg)Attachment tile radius (shared with the composer’s preview tiles).
--aparte-thumb-name-color#ffffffFilename overlaid on a tile.
--aparte-thumb-name-scrimGradient behind that filename, so it stays legible over any image.
--aparte-thumb-name-padding14px 5px 4pxPadding of the filename overlay.
--aparte-action-bar-gapvar(--aparte-space-2)Gap between action buttons (and between the footer’s two regions).
--aparte-action-bar-btn-sizevar(--aparte-btn-size-sm)Square size of an action button (the recipe’s small step); also the footer’s reserved height.
--aparte-action-bar-btn-colorvar(--aparte-text-muted)Action icon colour at rest.
--aparte-action-bar-btn-hover-bgvar(--aparte-surface-2)Action button hover background (the branch arrows reuse it).
--aparte-action-bar-btn-hover-colorvar(--aparte-text)Action icon colour on hover.
--aparte-branch-picker-gapvar(--aparte-space-2)Gap between the arrows and the position label.
--aparte-branch-picker-btn-colorvar(--aparte-text-muted)Arrow colour at rest.
--aparte-branch-picker-btn-hover-colorvar(--aparte-text)Arrow colour on hover (a disabled arrow is dimmed instead).
--aparte-branch-picker-label-sizevar(--aparte-font-size-sm)Type size of the position label.
--aparte-branch-picker-label-colorvar(--aparte-text-muted)Colour of the position label.
--aparte-branch-picker-label-min-width32pxReserved label width, so 9 / 9 growing to 10 / 12 does not shift the arrows.
--aparte-waiting-height1.5emMin height of the waiting region, so the first token does not jump the layout.
--aparte-waiting-dot-gapvar(--aparte-space-2)Gap between the three waiting dots.
--aparte-status-dot-size6pxDiameter of a waiting dot (shared with the status indicator).
--aparte-status-colorvar(--aparte-text-muted)Colour of the waiting dots (shared with the status indicator).
--aparte-error-solid#dc2626Ring drawn around the avatar while data-error is set. The error CARD itself belongs to the error segment renderer.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-chat-bubble streaming=""></aparte-chat-bubble>
el.addEventListener('aparte-attachment-preview', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.