Conversation
The transcript — it keeps the messages, renders each as a bubble, streams replies token by token and stays at the bottom while you are there.
-
<aparte-chat-viewport>
<aparte-chat-viewport style="height: 20rem"></aparte-chat-viewport>
<script>
// The viewport keeps the messages, builds a bubble for each, and stays pinned to the
// bottom while you are there; scroll up and the button brings you back.
const viewport = document.querySelector('aparte-chat-viewport');
for (const message of [
{ id: 'u1', role: 'user', content: 'Which transport should I use?', timestamp: Date.parse('2026-10-15T09:41:00') },
{ id: 'a1', role: 'assistant', content: 'Use **AparteDirectTransport** while you prototype: the browser talks to the model. Switch to **AparteBackendTransport** before you ship, so the key stays on your server.', timestamp: Date.parse('2026-10-15T09:41:04') },
{ id: 'u2', role: 'user', content: 'Does the component change between the two?', timestamp: Date.parse('2026-10-15T09:42:10') },
{ id: 'a2', role: 'assistant', content: 'No. The transport is configuration; the chat, its streaming and its tool calls stay the same.', timestamp: Date.parse('2026-10-15T09:42:13') },
]) viewport.appendMessage(message);
</script><aparte-chat-viewport> is the conversation as a list of messages. It renders a bubble for each,
keeps the view at the bottom while new text arrives, and stops following as soon as the reader
scrolls up; a button brings them back. It also holds the branches a retry creates, so moving
between two versions of a reply is the viewport re-rendering a different path.
Inside an <aparte-chat> it is chat.viewport. On its own, it is the
transcript of a page that lays out the rest itself.
Variants
Section titled “Variants”A reply streaming in
Section titled “A reply streaming in”<aparte-chat-viewport style="height: 18rem"></aparte-chat-viewport>
<script>
// What AparteClient does for you during a turn, by hand: open a message, append its
// tokens as they arrive, then complete it.
const viewport = document.querySelector('aparte-chat-viewport');
viewport.appendMessage({ id: 'u1', role: 'user', content: 'Explain the transports in one paragraph.', timestamp: Date.now() });
viewport.appendMessage({ id: 'a1', role: 'assistant', content: '', isStreaming: true, timestamp: Date.now() });
const words = 'A transport is the object that talks to the model. The direct one sends the request from the browser, the backend one sends it to your server, which holds the key. Both stream the same events, so the chat cannot tell them apart.'.split(' ');
let i = 0;
const tick = setInterval(() => {
viewport.appendToken('a1', (i ? ' ' : '') + words[i++]);
if (i === words.length) { clearInterval(tick); viewport.completeMessage('a1'); }
}, 90);
</script>A turn is appendMessage(), then appendToken() for each chunk, then completeMessage().
AparteClient does exactly this for you; drive it by hand when your own code runs the loop.
While the messages load
Section titled “While the messages load”<!-- loading: a conversation was chosen and its messages are on their way. The viewport
shows the outline of a conversation instead of an empty column. -->
<aparte-chat-viewport loading style="height: 16rem"></aparte-chat-viewport>loading says a conversation was chosen and its messages are on their way: the viewport shows
the outline of a conversation rather than an empty column, and says so to a screen reader.
<aparte-chat-viewport loading=""></aparte-chat-viewport>el.addEventListener('aparte-segment-update', (e) => use(e.detail));<aparte-chat-viewport loading=""></aparte-chat-viewport>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 loading={false} would render loading="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template>
<aparte-chat-viewport loading=""
@aparte-segment-update="(e) => use(e.detail)"
></aparte-chat-viewport>
</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-viewport loading=""
on:aparte-segment-update={(e) => use(e.detail)}
></aparte-chat-viewport>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteChatViewportDirective } from '@aparte/angular';<aparte-chat-viewport [loading]="true"
(segmentUpdate)="use($event)"></aparte-chat-viewport>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.
<aparte-chat-viewport> · class AparteChatViewport
Attributes
| Attribute | Description |
|---|---|
scroll-threshold | How close to the bottom still counts as “at the bottom”. |
max-rendered-bubbles | Caps how many bubbles stay in the DOM; older ones are released. |
loading | The transcript is on its way: a conversation was chosen and its messages are being fetched. The element draws two skeleton turns, marks its scroll surface aria-busy and names the wait for a screen reader; aparte-chat[center-empty] does not count a loading viewport as empty. Set by AparteConversationController through its binding, or by hand from a loop of your own (setLoading(true)). |
framework-managed | The wrapper’s explicit hands-off signal: set it and this element builds no wrapper of its own and relocates none of the nodes the FRAMEWORK renders into it, because the framework owns them. Not “none of its children”: core’s own scroll-to-bottom button is re-appended whenever it stops being last, and that path runs in this mode only. All four wrappers set it. |
data-busy | Reflected BY the element while a turn streams: the transcript is read-only meanwhile, and every bubble inside reads it (at connect, and when it changes). The vanilla path derives it from the repository; a framework host sets it through setTranscriptBusy(). Read-only from the outside. |
Properties
| Property | Type | Description |
|---|---|---|
loading | boolean | The transcript is on its way: a conversation was chosen and its messages are being fetched. Two skeleton turns stand where they will be, the scroll surface is aria-busy, and a visually hidden line names the wait. Reflected as the loading attribute — AparteConversationController sets it through its binding; a loop of your own sets it by hand. Not the same thing as data-busy, which is a reply streaming into a transcript that is already there. |
Methods
| Method | Description |
|---|---|
setLoading(loading: boolean): void | |
configure(config: AparteViewportConfig): void | Configure viewport with options |
appendToken(messageId: string, chunk: string): void | Append a token chunk to a message’s content (simple text streaming) |
appendToSegment(messageId: string, segmentId: string, chunk: string): void | Append content to a specific segment within a message |
setTranscriptBusy(busy: boolean): void | The transcript’s read-only-while-streaming flag — data-busy on this element and fanned out to the bubbles it holds. The vanilla path derives it from the repository (_syncBusy); a framework host, whose messages live outside the repository during a turn, writes it directly from its own streaming id. |
addSegment(segment: AparteSegment): void | Add a new segment. Two calling conventions are accepted: - addSegment(segment) — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - addSegment(messageId, segment) — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by AparteClient no longer drops text (the args used to bind one position short, creating a phantom message). |
addSegment(messageId: string, segment: AparteSegment): void | Add a new segment. Two calling conventions are accepted: - addSegment(segment) — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - addSegment(messageId, segment) — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by AparteClient no longer drops text (the args used to bind one position short, creating a phantom message). |
updateSegment(segmentId: string, updates: Partial<AparteSegment>): void | Update a segment. updateSegment(segmentId, updates) (1-arg client convention → current message) or updateSegment(messageId, segmentId, updates) (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the updates object in the 1-arg form. |
updateSegment(messageId: string, segmentId: string, updates: Partial<AparteSegment>): void | Update a segment. updateSegment(segmentId, updates) (1-arg client convention → current message) or updateSegment(messageId, segmentId, updates) (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the updates object in the 1-arg form. |
removeSegment(segmentId: string): void | Remove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit). |
removeSegment(messageId: string, segmentId: string): void | Remove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit). |
startSegment(messageId: string, segment: AparteSegment): void | Start a new streaming segment (e.g., thinking or code block) Creates the segment and marks it as streaming |
completeSegment(messageId: string, segmentId: string): void | Complete a streaming segment |
setUsage(messageId: string, usage: AparteUsage): void | Persist token usage on a message and propagate to the live bubble, which is what allows the info (“i”) action to render — provided the app declared it with aparteGlobalConfig.setBubbleActions({ info: true }); it is off by default, since the popover it opens belongs to the app. |
completeMessage(messageId: string): void | Mark a message as finished streaming |
updateMessage(messageId: string, updates: Partial<AparteMessage>): void | Atomic update for a message by ID Supports updating content, status, segments, and other metadata An id the tree does not hold is a silent no-op, deliberately: the message may have been truncated away by an edit while a turn was still writing to it, and inventing it back would put a message the reader deleted on screen. It does mean a late error card can have nowhere to land — aparte-message-error still fires, so a host that wants to show one has the event. |
addMessage(message: AparteMessage): void | Add a complete message to the message registry. |
appendMessage(message: AparteMessage, options?: { historical?: boolean }): void | Append a new message and create its bubble in the DOM. Implements the same contract as the Angular wrapper’s appendMessage(), making aparte-chat-viewport a fully standalone target for aparte-client. When _frameworkManagedDOM is true, only the internal repo is updated — the framework owns the DOM and will create the bubble element itself. |
updateLastMessage(content: string, options?: { append?: boolean }): void | Update the last message content, optionally appending. Implements the same contract as the Angular wrapper’s updateLastMessage(),’ making aparte-chat-viewport a fully standalone streaming target for aparte-client. |
addBranch(messageId: string): void | Add a new sibling branch to an assistant message (retry flow). Creates a new empty assistant message as a sibling of messageId under the same parent, switches the active branch to it, and re-renders the active path. |
addSiblingOf(existingId: string, newMessage: AparteMessage): string | null | Add a new message relative to existingId, switch to it, and re-render. Role-aware semantics: - existingId is an assistant message → create a sibling (same parent), so the active path replaces the old response with the new one. - existingId is a user message → create a child of that message, so the user message stays on the active path and the new response follows it. Returns the new message’s ID, or null if existingId is not found. |
navigateBranch(messageId: string, direction: 'prev' | 'next'): void | Navigate to the previous or next sibling branch of a message. Triggers a full re-render of the active path. |
truncateResponsesAfter(userMessageId: string): void | Remove ALL responses to a user message (every child branch) and set head back to userMessageId. Cleaner than truncateFrom for edit flows: it discards stale sibling branches so the regenerated response starts alone. |
truncateFrom(messageId: string): void | Remove all messages from messageId onwards (inclusive) from state and DOM. Used by edit to truncate history before re-generating. |
getMessage(messageId: string): AparteMessage | undefined | Get a message by ID |
getMessages(): AparteMessage[] | The messages on the currently ACTIVE path, root → head — not the whole tree. A message that was retried contributes only the branch currently selected; exportTree() is what returns every sibling. |
exportTree(): ExportedMessageRepository | Export the full conversation tree (all branches, not just the active path). The returned snapshot can be persisted and restored via importTree(). |
importTree(tree: ExportedMessageRepository): void | Import a previously-exported tree snapshot, restoring the full branch topology and the active head. Replaces any existing repo content. Always calls _reRenderActivePath(): - In native DOM mode: rebuilds bubble elements. - In framework-managed mode: skips DOM manipulation but dispatches aparte-path-changed with sibling metadata so the wrapper can update branch arrows on already-rendered bubbles. |
clearAll(options?: { revokeAttachments?: boolean }): void | Clear all messages and remove all bubble elements from the DOM. Also dispatches a aparte-reset-done event. In framework-managed mode the DOM is owned by the host framework (Angular |
clearMessages(): void | Clear all messages |
setMessages(messages: AparteMessage[]): void | Replace the entire message list in one shot. Used when switching conversations: clears existing repo + DOM, then appends each message. In framework-managed mode the framework re-renders the bubble DOM itself; we only update the internal repo (used by aparte-client to build chat history). |
scrollToBottom(): void | Scroll to bottom of viewport |
resetSpacer(): void | Reset the bottom spacer to 0 height immediately and freeze it for 350 ms so the host-app layout transition (e.g. flex: 0→1 animation) does not trigger a premature recalculation with mid-animation geometry. Call before a full messages swap. |
setAutoScroll(enabled: boolean): void | Enable or disable auto-scroll |
setFrameworkManagedDOM(managed: boolean): void | Signal that a framework (e.g. Angular) manages the bubble DOM. When true, branch navigation dispatches aparte-path-changed without clearing/rebuilding the messages wrapper — the framework re-renders instead. |
requestSmoothScroll(): void | Request that the next auto-scroll triggered by a DOM mutation uses smooth behaviour instead of instant. Call this just before adding a user message bubble so the viewport animates down rather than jumping. Resets automatically after the first auto-scroll fires. |
Events
| Event | Type | Description |
|---|---|---|
aparte-segment-update | CustomEvent<AparteSegmentUpdateEventDetail> | A segment grew or settled during a stream. |
aparte-reset-done | CustomEvent | clearAll() finished emptying the transcript. No detail. |
aparte-path-changed | CustomEvent<ApartePathChangedEventDetail> | The active branch path changed, after a retry fork or a navigation. |
CSS variables
| Variable | Default | Description |
|---|---|---|
--aparte-viewport-padding | var(--aparte-space-8) | Padding around the transcript — on .aparte-messages-wrapper, or on the host itself in framework-managed mode, where the auto-scroll spacer is added on top of it. A container narrower than 520px tightens it in the default mode only: that rule reassigns the variable on .aparte-messages-wrapper, which framework-managed mode never builds. |
--aparte-message-gap | var(--aparte-space-6) | Gap between consecutive bubbles in the transcript column (both DOM modes). Shared: it is also the avatar-to-content gap inside a bubble. |
--aparte-scrollbar-thumb | var(--aparte-neutral) | Colour of the transcript’s scrollbar thumb. A host page with a scrollbar of its own sets this and the track so the chat’s does not read as a second, foreign scrollbar. |
--aparte-scrollbar-track | transparent | Colour of the transcript’s scrollbar track. |
--aparte-scrollbar-width | 6px | Width of the WebKit scrollbar on the scroll surface. Firefox and the standard property use scrollbar-width: thin and ignore it. |
--aparte-transcript-inset | var(--aparte-viewport-padding) | Written BY the viewport on the chat host: how far from the host’s inline edge its rows start (padding plus the scrollbar gutter, at the current container step). The composer pads by it, so the two boxes share one edge at every width. Read-only from the outside. |
--aparte-bottom-inset | 0px | How much of the transcript’s bottom is covered by content floating over it. Written by the viewport itself under [overlay-composer] (never set it there — it would be overwritten); a host that overlays a composer of its own, without the attribute, sets it by hand and the spacer, the container padding and the scroll button all clear it. |
--aparte-scroll-btn-size | var(--aparte-btn-size-lg) | Diameter of the scroll-to-bottom button. A coarse pointer raises it to --aparte-touch-target-size. |
--aparte-scroll-btn-shadow | 0 2px 8px rgba(0, 0, 0, 0.12) | Its shadow; the dark theme sets a heavier one. |
Accessibility
Section titled “Accessibility”- The transcript is a focusable
role="log", named by thetranscriptlocale key. - While
loading, it isaria-busyand a visually hidden line names the wait, because screen readers ignorearia-busyalone. - The scroll-to-bottom button is named by the
scrollToBottomlocale key.
Related
Section titled “Related”- Message — one bubble of the transcript.
- Branches — the versions a retry leaves behind.
- Scroll rail — an outline of the conversation beside it.