Chat bubble
<aparte-chat-bubble>
Class: AparteChatBubble
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.
The bubble element is content-visibility: auto for its streaming/scroll performance
(measured, not assumed — see bubble.css), and that containment makes the bubble
the containing block for position: fixed descendants: a popover mounted inside it
(an <aparte-select> from a registerToolRenderer renderer, say) is clipped to the
bubble’s own box, not the window. Mount it outside instead — Custom tool
renderer shows the pattern.
Example
Section titled “Example”<!-- 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>Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
data-role | The 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.) |
content | Plain text content, for a bubble with no segments. |
timestamp | Epoch milliseconds OR a date string: _updateTimestamp accepts either and only coerces when the value is numeric. |
message-id | How streaming and the action bar address this bubble. |
streaming | Hides the action bar and shows the caret while a reply is in flight. |
name | The display name in the header. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
appendToken(chunk: string): void | Append a token chunk (for streaming) |
setContent(content: string): void | Set content directly |
getContent(): string | Get current content |
setSegments(segments: AparteSegment[]): void | Set segments for rich content |
addSegment(segment: AparteSegment): void | Add a segment |
updateSegment(segmentId: string, updates: Partial<AparteSegment>): void | Update a specific segment |
appendToSegment(segmentId: string, content: string): void | Append content to a segment |
getSegments(): AparteSegment[] | Get all segments |
removeSegment(segmentId: string): void | Scoped 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[]): void | Set attachments (chips shown above message content, user role only) |
setUsage(usage: AparteUsage | null | undefined): void | Set 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): void | Update 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<AparteMessage>): void | Atomic update for the message |
setTranscriptBusy(busy: boolean): void |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-attachment-preview | CustomEvent<AparteAttachmentPreviewEventDetail> | An attached image was clicked, asking the app to open it full-size. |
aparte-action | CustomEvent<AparteActionEventDetail> | A custom action-bar button was pressed. |
aparte-retry | CustomEvent<AparteRetryEventDetail> | Retry was pressed; the host forks the turn. |
aparte-feedback | CustomEvent<AparteFeedbackEventDetail> | Thumbs up or down. |
aparte-message-info | CustomEvent<AparteMessageInfoEventDetail> | The info affordance was pressed. |
aparte-edit | CustomEvent<AparteEditEventDetail> | An edit was saved. |
aparte-branch-navigate | CustomEvent<AparteBranchNavigateEventDetail> | The ‹1/2› picker moved between sibling versions. |
aparte-link-click | CustomEvent<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. |
Theming
Section titled “Theming”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.
<aparte-chat-bubble>
Section titled “<aparte-chat-bubble>”| Variable | Default | Description |
|---|---|---|
--aparte-message-gap | var(--aparte-space-6) | Gap between the avatar column and the body (the viewport reuses it between messages). |
--aparte-message-padding-block | var(--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-inline | var(--aparte-space-6) | Horizontal padding of one message row. |
--aparte-message-max-width | 800px | Width of the centred message row. |
--aparte-message-content-radius | var(--aparte-radius-bubble) | Radius of the painted content box. |
--aparte-message-content-padding | var(--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-user | — | Background 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-assistant | transparent | Background of the assistant box — transparent on purpose (AI-chat convention, not messaging). |
--aparte-message-content-text-user | var(--aparte-text) | Text colour inside the user box. |
--aparte-message-content-text-assistant | var(--aparte-text) | Text colour inside the assistant box. |
--aparte-avatar-size | 32px | Square size of the avatar slot. |
--aparte-avatar-radius-ratio | 0.28 | Avatar 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-ratio | 0.34 | Size of the initial, as a fraction of the avatar’s size. |
--aparte-avatar-bg-user | var(--aparte-primary) | Avatar background, user role. |
--aparte-avatar-text-user | var(--aparte-text-inverse) | Avatar text colour, user role. |
--aparte-avatar-bg-assistant | var(--aparte-surface-3) | Avatar background, assistant role. |
--aparte-avatar-text-assistant | var(--aparte-text) | Avatar text colour, assistant role. |
--aparte-avatar-image-user | none | background-image for the user avatar — a logo with no AvatarProvider and no JS. |
--aparte-avatar-image-assistant | none | background-image for the assistant avatar. |
--aparte-avatar-image-size | 90% | background-size for both avatar images. |
--aparte-name-font-size | var(--aparte-font-size-md) | Sender name in the header. |
--aparte-name-color | var(--aparte-text) | Sender name colour. |
--aparte-timestamp-font-size | var(--aparte-font-size-sm) | Timestamp in the header. |
--aparte-timestamp-color | var(--aparte-text-muted) | Timestamp colour. |
--aparte-content-font-size | var(--aparte-font-size-base) | Body type size, applied to both the plain-content and the segments container. |
--aparte-content-color | var(--aparte-text) | Body text colour. |
--aparte-content-line-height | var(--aparte-line-height-loose) | Body line height. |
--aparte-attachments-max-height | 140px | Cap on the sent-attachment strip; past it the strip scrolls instead of growing. |
--aparte-attachment-image-size | 72px | Tile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation. |
--aparte-thumb-radius | var(--aparte-radius-lg) | Attachment tile radius (shared with the composer’s preview tiles). |
--aparte-thumb-name-color | #ffffff | Filename overlaid on a tile. |
--aparte-thumb-name-scrim | — | Gradient behind that filename, so it stays legible over any image. |
--aparte-thumb-name-padding | 14px 5px 4px | Padding of the filename overlay. |
--aparte-action-bar-gap | var(--aparte-space-2) | Gap between action buttons (and between the footer’s two regions). |
--aparte-action-bar-btn-size | var(--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-color | var(--aparte-text-muted) | Action icon colour at rest. |
--aparte-action-bar-btn-hover-bg | var(--aparte-surface-2) | Action button hover background (the branch arrows reuse it). |
--aparte-action-bar-btn-hover-color | var(--aparte-text) | Action icon colour on hover. |
--aparte-branch-picker-gap | var(--aparte-space-2) | Gap between the arrows and the position label. |
--aparte-branch-picker-btn-color | var(--aparte-text-muted) | Arrow colour at rest. |
--aparte-branch-picker-btn-hover-color | var(--aparte-text) | Arrow colour on hover (a disabled arrow is dimmed instead). |
--aparte-branch-picker-label-size | var(--aparte-font-size-sm) | Type size of the position label. |
--aparte-branch-picker-label-color | var(--aparte-text-muted) | Colour of the position label. |
--aparte-branch-picker-label-min-width | 32px | Reserved label width, so 9 / 9 growing to 10 / 12 does not shift the arrows. |
--aparte-waiting-height | 1.5em | Min height of the waiting region, so the first token does not jump the layout. |
--aparte-waiting-dot-gap | var(--aparte-space-2) | Gap between the three waiting dots. |
--aparte-status-dot-size | 6px | Diameter of a waiting dot (shared with the status indicator). |
--aparte-status-color | var(--aparte-text-muted) | Colour of the waiting dots (shared with the status indicator). |
--aparte-error-solid | #dc2626 | Ring drawn around the avatar while data-error is set. The error CARD itself belongs to the error segment renderer. |
In a framework
Section titled “In a framework”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));<aparte-chat-bubble streaming=""></aparte-chat-bubble>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and streaming={false} would render streaming="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-chat-bubble streaming="" @aparte-attachment-preview="(e) => use(e.detail)" ></aparte-chat-bubble></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-chat-bubble streaming="" on:aparte-attachment-preview={(e) => use(e.detail)}></aparte-chat-bubble>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteChatBubbleDirective } from '@aparte/angular';<aparte-chat-bubble [streaming]="true" (attachmentPreview)="use($event)"></aparte-chat-bubble>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.