Channel Chat

A many-person channel log, the team-chat and radio-net shape: messages grouped by author, day separators, system notices, your own run tinted, a log that follows new messages until you scroll up, and a composer with a counter and a rejected notice.

TAC-2 · Patrol

154.250 MHz
Yesterday
James Hopper1-DAVID-21
Clearing the scene on Vinewood, 10-8.
Today
Elena Vásquez1-LINCOLN-4
Morning all. Starting patrol on the east side.
Anyone near Mirror Park? Reported 10-50, minor.
Tariq Okafor2-KING-7
En route, ETA four minutes.
James Hopper joined TAC-2
You1-ADAM-12
Copy. I'll take traffic control at the junction.
Plate on the second vehicle: 8KXZ 412.
Tariq Okafor2-KING-7
On scene. No injuries, both drivers cooperative.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add channel-chat

Usage#

usage.tsxTSX
import {  ChannelChat,  ChannelChatViewport,  ChannelChatGroup,  ChannelChatAvatar,  ChannelChatGroupHeader,  ChannelChatAuthor,  ChannelChatTime,  ChannelChatMessage,  ChannelChatComposer,  ChannelChatInput,  ChannelChatSend,  groupChannelMessages,} from "@/components/ui/channel-chat"

Chat or Channel Chat#

Chat is a two-party thread: you and an assistant, a bubble or a document each, built for streaming answers and reasoning. A channel has many authors talking at once, so it needs different structure: author groups with a name, a callsign and an avatar, separators between days, system notices for who joined, and a log that can be read while it moves. That is this component. It is built out of the same parts: the log scrolls in ScrollArea, the avatar is Avatar, separators are Divider, and the composer is PromptInput laid out as one row.

Anatomy#

Every part is a named export and every part is optional: a log without a header, a composer on its own, a group without an avatar (the text column keeps its place). Grouping is data, not markup: groupChannelMessages turns your flat list into rows, and you render each row with the parts.

import {  ChannelChat,  ChannelChatAuthor,  ChannelChatAvatar,  ChannelChatCallsign,  ChannelChatComposer,  ChannelChatCount,  ChannelChatEmpty,  ChannelChatGroup,  ChannelChatGroupHeader,  ChannelChatHeader,  ChannelChatInput,  ChannelChatMessage,  ChannelChatMeta,  ChannelChatSend,  ChannelChatSeparator,  ChannelChatSystem,  ChannelChatTime,  ChannelChatTitle,  ChannelChatViewport,} from "@/components/ui/channel-chat"<ChannelChat>  <ChannelChatHeader>    <ChannelChatTitle />    <ChannelChatMeta />  </ChannelChatHeader>  <ChannelChatViewport>    <ChannelChatSeparator />    <ChannelChatGroup>      <ChannelChatAvatar />      <ChannelChatGroupHeader>        <ChannelChatAuthor />        <ChannelChatCallsign />        <ChannelChatTime />      </ChannelChatGroupHeader>      <ChannelChatMessage />    </ChannelChatGroup>    <ChannelChatSystem />    <ChannelChatEmpty />  </ChannelChatViewport>  <ChannelChatComposer>    <ChannelChatInput />    <ChannelChatCount />    <ChannelChatSend />  </ChannelChatComposer></ChannelChat>

Grouping and separators#

Consecutive messages from one author within windowMs (5 minutes) of the previous one share a header. A new local day opens a day separator, a silence of gapMs (an hour) on the same day opens a time separator, and a system notice always ends the run. Pass own on your own groups: they take a soft brand wash and your name in the link ink.

Yesterday
James Hopper1-DAVID-21
Clearing the scene on Vinewood, 10-8.
Today
Elena Vásquez1-LINCOLN-4
Starting patrol on the east side.
Two lines within five minutes share one header.
Elena Vásquez1-LINCOLN-4
Eleven minutes later: a new header.
Channel switched to TAC-2
You1-ADAM-12
Your own run carries the brand wash.
11:30
Tariq Okafor2-KING-7
An hour of silence earns a time separator.

Following and jump to latest#

The viewport opens on the newest message and follows new ones while you are at the bottom. Scroll up and it holds your place: a pill appears over the log, counting what arrived since, and takes you back down. Only a move up leaves the bottom, so the log never loses you halfway through its own smooth scroll. Sending from the composer jumps to the newest message too. Scroll up in the log below, then receive a message.

Today
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.
Elena Vásquez1-LINCOLN-4
Status check, all units.
Tariq Okafor2-KING-7
10-8, available.
James Hopper1-DAVID-21
Holding at the checkpoint.

What the log opened with is history and never animates. Only rows that mount after its first commit fade in, and a batch of more than five messages in one commit (a backlog that loaded late, a reconnect) lands still, as history. To open a different channel as fresh history, give the viewport a key per channel.

Empty states#

ChannelChatEmpty is our compact EmptyState, centered in the room it gets: inside the viewport for a quiet channel, or in place of it when there is no log at all. A composer with nowhere to send takes disabled.

TAC-4

155.010 MHz
No messages yet

Say something to the units on this channel.

Radio off

Turn the radio on and join a channel to chat.

Composer#

maxLength caps the draft, and ChannelChatCount shows length/max only once the draft nears it (20 characters out by default, or counterFrom). When the server refuses a message (too fast, filtered, not delivered), pass the reason as notice: it tucks in behind the composer as PromptInput's banner and is announced as an alert. Enter sends, Shift+Enter breaks the line, and focus returns to the field after a send.

17 characters left

Accessibility#

  • The log is role="log" with aria-live="polite": each arrival is read once, without interrupting. Name it with aria-label (the channel).
  • Avatars are decorative (the name sits beside them). Times are <time> elements; pass dateTime.
  • The hidden jump pill is out of the tab order and the accessibility tree; it rejoins both when it shows.
  • The counter is a live region that stays mounted, and reads “15 characters left” rather than the visual 185/200. The rejected notice is role="alert".
  • Arrivals honor reduced motion: no fade, and the jump is instant.

Older engines#

Built for Chromium 103 (in-game browsers such as CEF): no :has(), container queries or standalone translate/scale properties. The pill moves with transform, and the field grows with PromptInput's measured fallback where field-sizing is missing.

API reference#

ChannelChat

The panel, a <section> laid out as a flex column. Size it (h-96, or flex-1 min-h-0); the viewport takes the room between header and composer. It lets the composer reach the viewport, so a send jumps to the newest message. Every part accepts className, merged last.

Data attributeDescription
data-slot="channel-chat"On the root.

ChannelChatHeader

A <header> with a hairline under it. A leading icon is muted.

ChannelChatTitle

The channel name, an <h2> that truncates.

ChannelChatMeta

Trailing meta in tabular figures (a frequency, a member count), a <span> pushed to the right edge.

ChannelChatViewport

The scrolling log, role="log" with polite announcements. Give it a key per channel to open another channel as fresh history. Other props go on the frame.

PropTypeDefaultDescription
stickToBottombooleantrueFollows new messages while the reader is at the bottom, and shows the jump pill when not. Scrolling up is never yanked back down.
jumpLabel(unread: number) => React.ReactNode-The pill's label, given how many messages arrived while scrolled up. Defaults to "n new messages" or "Jump to latest".
viewportRefReact.Ref<HTMLDivElement>-A ref to the scrolling element itself.
aria-labelstring-Names the log (the channel).
aria-labelledbystring-Names the log from an element on the page.
Data attributeDescription
data-followingPresent while the reader is at the bottom.
data-slot="channel-chat-jump"The jump pill; carries data-state (visible or hidden).

ChannelChatSeparator

A day or quiet-gap separator: a labeled hairline (our Divider), read as plain text. Children are the label.

ChannelChatGroup

A run of messages from one author. The avatar sits in a reserved gutter, so a group without one keeps the same text column.

PropTypeDefaultDescription
ownbooleanfalseYour own run: a soft brand wash and the name in the link ink.
Data attributeDescription
data-ownPresent while own.

ChannelChatAvatar

The author's avatar in the group's gutter, hidden from assistive tech. Every Avatar prop is forwarded.

PropTypeDefaultDescription
srcstring-The photo.
namestring-Derives two initials for the fallback.
size"xs" | "sm" | "md" | "lg" | "xl""sm"Our Avatar sizes.
childrenReact.ReactNode-Overrides the fallback initials.

ChannelChatGroupHeader

The group's header row, baseline-aligned.

ChannelChatAuthor

The author's name, a <span> that truncates.

ChannelChatCallsign

The callsign in mono, a <span>.

ChannelChatTime

A <time> in tabular figures. Pass dateTime (ISO) for a machine-readable stamp.

ChannelChatMessage

One line of a group. Keeps the sender's line breaks and breaks a long token anywhere.

Data attributeDescription
data-freshStamped when it arrives after the log settled; history never animates.

ChannelChatSystem

A system notice between groups: a join, a leave, a channel switch.

PropTypeDefaultDescription
tone"default" | "info" | "success" | "warning" | "destructive""default"Tints the leading icon only; the text stays quiet.
Data attributeDescription
data-toneThe current tone.

ChannelChatEmpty

Our EmptyState in a centering frame. Every EmptyState prop is forwarded; className goes on the frame.

PropTypeDefaultDescription
density"comfortable" | "compact""compact"The EmptyState density.

ChannelChatComposer

The message composer: our PromptInput as one row, with the notice as its banner. Compose ChannelChatInput, ChannelChatCount and ChannelChatSend inside.

PropTypeDefaultDescription
valuestring-Controlled draft. Omit (and use defaultValue) to let the composer own it.
defaultValuestring-The initial draft, uncontrolled.
onValueChange(value: string) => void-Called with the draft as it changes.
onSubmit(text: string) => void-Receives the trimmed text. Never fires empty or disabled. Uncontrolled, the field clears itself, takes focus back and the log jumps to the newest message.
disabledbooleanfalseNobody to talk to: the field and the send button go inert.
maxLengthnumber-Caps the draft and turns the counter on.
counterFromnumbermaxLength - 20The draft length at which the counter appears.
noticeReact.ReactNode-The rejected-message notice, tucked behind the composer and announced as an alert. null removes it.
noticeTone"default" | "info" | "warning" | "destructive""destructive"The notice's tone (PromptInputBanner tones).
onNoticeDismiss() => void-Adds a close button to the notice that calls this.
Data attributeDescription
data-disabledPresent while disabled.
data-slot="channel-chat-notice"The notice banner.

ChannelChatInput

The composer's auto-growing field. Enter sends, Shift+Enter breaks the line. Takes every PromptInputTextarea prop.

PropTypeDefaultDescription
placeholderstring"Write a message…"Also names the field when there is no aria-label.

ChannelChatCount

The draft counter, length/max, shown once the draft nears maxLength. Turns destructive at the cap. Renders nothing without maxLength.

PropTypeDefaultDescription
srLabel(left: number) => string(left) => "n characters left"Words the remaining count for screen readers.
Data attributeDescription
data-nearPresent once the draft reaches counterFrom.
data-fullPresent at the cap.

ChannelChatSend

The send button, disabled until there is a draft. Takes every PromptInputSubmit prop.

PropTypeDefaultDescription
sendLabelstring"Send message"The button's accessible name.
childrenReact.ReactNode-Replaces the paper plane icon.

groupChannelMessages

groupChannelMessages(messages, options) turns a flat list into rows: { type: "separator", kind: "day" | "gap", at }, { type: "group", author, at, items } and { type: "system", at, item }, each with a key. It formats nothing; word the labels in your locale. Its options:

PropTypeDefaultDescription
getKey*(message: T) => string | number-A stable key per message (its id).
getAuthor*(message: T) => string | number-Who sent it. Two messages group only when this matches.
getTime*(message: T) => number | Date-When it was sent, as epoch ms or a Date.
isSystem(message: T) => boolean-Marks a system notice. Never grouped.
windowMsnumber5 minutesHow close a message must follow the previous one to join its group.
gapMsnumber1 hourA same-day silence long enough for a time separator. 0 turns gap separators off.
leadingSeparatorbooleantrueInserts a separator before the very first message.

channelChatVariants

The tv recipe behind every part, for styling a part of your own to match.

FAQ#

Because the log has to stay composable: you decide what a row looks like, and the parts never inspect each other. groupChannelMessages is a plain function, so it also runs on the server or in a test, and it never formats a date: you word Today and 14:02 in your own locale.