Casey
C
The same chat components can live directly in a page. Try sending a message.
Reusable Chat UI components, working demos, and a Cal integration guide for engineers and agents.
This page turns the Chat UI Primitives checklist into a working example and maps the pieces as generic, reusable UI. The Cal Events Chat project is one planned consumer.
Use this page as the implementation entry point. Read the sections in this order:
| Layer | Status and source |
|---|---|
| Reusable presentation | Implemented locally in packages/coss-ui/src/chat/; import directly from @coss/ui/chat/*. No chat provider or backend schema is required. |
| Complete drawer | Working design-system example with synthetic data and local mutations. It is an integration reference, not a connected Cal chat. |
| Cal integration | The application must provide authorization, persistence, live updates, eligibility, and delivery data. See the explicit contract decisions. |
| Coss distribution | Planned source-registry rebuild. The workspace package is private; no public Chat UI registry installation is available yet. |
| Anatomy maps | Include exploration items beyond the working example. They are not an implementation checklist for the first release. |
For agents, raw Markdown and Copy Markdown include the code and integration guidance below. Interactive previews alone do not expose their implementation in Markdown.
Open the chat drawer to try one group conversation across multiple days. It rises from the bottom below md and opens from the right on wider screens. Select the member names below the title to view the group, mute a guest, remove them, or ban them. The current user is a co-host; host and co-host rows have no moderation menu. Scroll the timeline to see a date pill stay in view until the next day's messages arrive. Reply to a message, add a reaction, or type @ to mention someone. Sending a message triggers a simulated response, so you can try the typing and read states. Messages, membership, and drafts stay in local demo state.
Use the state triggers below the preview to open the drawer with a long message, pinned message, one or several people typing, first-message, loading, history-error, disconnected, access-lost, removed-from-chat, banned-from-chat, left-chat, unread, or delivery/retry example. The default conversation has no pinned message; choose Pinned message to see the pinned bar. The Long message state shows the expand and collapse control. The First message state introduces the event chat and shows who can access it; joining reveals the empty conversation and composer. The history-error, access-lost, removed-from-chat, banned-from-chat, and left-chat previews show a Coss Empty with no composer. Reconnect briefly shows its loading state before restoring the conversation, and Rejoin chat returns from the left-chat state. Opening a state resets sample messages and cancels pending replies, while keeping your draft. Loading and receipt examples stay in their illustrated states until you interact. Recovery and retry actions are local simulations.
Choose Message info from one of your messages’ action menus to inspect read, delivered, and pending recipients in a nested drawer. It uses the Coss nested-drawer pattern, keeping the conversation underneath. The selected message uses the same outgoing bubble and content as the conversation, and only recipient sections with people appear. The “Sounds good” message demonstrates partial receipts. Back, Escape, or dismissing the nested drawer returns to the conversation with your draft and reading position preserved.
Design, details, and everything in between.
Jordan·See you at two. I’ll bring the notes.
10:51Choose Leave chat in the group members drawer footer to open a confirmation. Cancel keeps the conversation and draft; confirming shows “You left this chat,” clears the draft, and stops simulated replies. Rejoin chat restores the conversation. The event ticket stays valid. This is a local demo interaction.
Remove from chat kicks the person out by ending their membership. It does not create a ban: they can join again through their ticket invitation, subject to eligibility. The confirmation explains this, and the Removed from chat demo state includes Rejoin chat.
Ban from chat also removes membership and creates a persistent ban. After confirmation, the person appears under Banned users and cannot join again until a moderator selects Unban. The Banned from chat state has no rejoin action. In the intended UI flow, Unban returns the person to Current members and restores their access. Their old receipt watermark is not restored.
Both actions preserve the person's messages, quoted replies, reactions, and event ticket. A mute remains independent and is not cleared by leaving, removal, banning, or unbanning. Message info uses the current recipient list.
Try the full moderator flow by muting Alex, banning them, and then selecting Unban. Alex returns to Current members with the Muted badge preserved, and their messages remain visible throughout. The Removed from chat and Banned from chat state triggers show the two different guest access screens.
Muting currently demonstrates the moderator's member-list action and badge. A muted guest's own view, with readable history and a notice replacing the composer, is not yet demonstrated.
For integration, supply the current roster and ban list independently. PR #9727 provides the persisted ban data; the consuming app must wire Remove to a moderator-authorized membership-removal operation and Ban to the ban operation. The PR's unbanMember only deletes the restriction: the intended Unban UI also requires a server operation that restores eligible membership. The backend must check eligibility and authorize the combined operation before the UI moves the person back. The demo simulates its successful result.
The current PRO-2343 description still uses “Remove” for a ban. That engineering contract needs to be reconciled with the distinct Remove and Ban actions demonstrated here.
The demo's current user, Sam, is an accepted co-host. Open the ellipsis menu beside Alex Morgan’s message, select Delete message, and confirm. Cancel preserves the message; confirmation replaces it with Removed by a host, removes its reactions and any pin, and updates the quote in Sam’s reply. You can try this in the Pinned message state to see the pin disappear too.
Hosts and accepted co-hosts may delete guests’ messages, including messages from removed or banned guests. Everyone may delete their own messages, which show Message deleted. Other hosts’ and co-hosts’ messages are protected, so their menus omit Delete. Deletion erases the body and has no Undo. The consuming app must enforce these permissions server side, as specified in PRO-2342.
The example is built from a cohesive family of ready-made chat components in @coss/ui/chat/chat-*. The components share layout, styling, and interaction conventions and can be composed in a drawer or directly in a page. They are being prepared for rebuilding in Coss; they are not published registry entries yet.
| Component | Direct import | Owns |
|---|---|---|
ChatBubble, ChatMessageLayout | @coss/ui/chat/chat-bubble, @coss/ui/chat/chat-message-layout | Bubble surface and message layout parts for custom compositions |
ChatMessage | @coss/ui/chat/chat-message | Sender/avatar layout, grouped corners, bubble, and placement of supplied actions, reactions, and metadata |
ChatConversation | @coss/ui/chat/chat-conversation | Scroll area, edge fades, jump control, and focused-reply overlay placement |
Composer | @coss/ui/components/composer | Shared input, structured draft, submit policy, and optional mentions |
ChatMessageActionToolbar | @coss/ui/chat/chat-message-actions | Optional reaction, reply, and menu controls with keyboard navigation |
ChatReactionChip, ChatReactionPicker | @coss/ui/chat/chat-reaction-chip, @coss/ui/chat/chat-reaction-picker | Reaction interaction, popup anchoring, and scroll dismissal |
ChatReplyPreview | @coss/ui/chat/chat-reply-preview | Optional focused reply, source geometry, transition, and cancellation |
ChatMemberList, ChatMemberSection | @coss/ui/chat/chat-members | Participant rows with supplied badges and actions |
ChatMessageInfo | @coss/ui/chat/chat-message-info | Supplied message preview and receipt groups |
ChatEmptyState, ChatLoadingState, ChatStatusBar | @coss/ui/chat/chat-states | State presentation with consumer-supplied copy and actions |
ChatDatePill, ChatUnreadDivider, ChatJumpToLatestButton, ChatTypingIndicator, ChatDeliveryIndicator, and ChatReplyReference each have a matching chat-* module. ChatMessageBubble is available for standalone previews, such as message info. ChatBubble and ChatMessageLayout live alongside the other chat components and can also be imported directly for custom compositions.
ChatMessage formats plain string children automatically. Supply React content for quotes, attachments, or custom text formatting; use ChatMessageText when you need to highlight known mentions. Supply actions, reactions, and footer as needed. grouped controls consecutive-message layout, and bubble={false} supports unavailable-message content. bubbleProps forwards native bubble props, a ref, and styling. The deleted-message placeholder is currently composed in the demo using bubble={false}; it is not yet a separately exported Chat UI component.
The consuming app owns message data, ordering/grouping, membership, permissions, translated labels, delivery calculations, and persistence. useChatScroll from @coss/ui/chat/hooks/use-chat-scroll owns following the conversation: it keeps the viewport at the end while content resizes, preserves reading position when scrolled up, and powers the jump control. Set scroll.atBottomRef.current = true before adding an outgoing message so sending returns to the latest message. Use paused while another panel or focused reply owns the viewport.
Chat compositions use the shared Coss Composer. The caller owns value, onValueChange, and onSubmit, and clears an accepted draft. Use getComposerText(value).trim() for a plain-text message. The inline example renders a native textarea; the drawer uses the default contenteditable input with mention triggers. Set submitOnEnter="desktop" so Return inserts a newline on touch devices and the send button submits. Supply accessible input and send-button labels.
ChatCollapsibleMessage owns text-only preview/expansion, with controlled open/onOpenChange or an uncontrolled defaultOpen. renderText can style known mentions using ChatMessageText. Attachments and other arbitrary bubble children remain outside text truncation. ChatMessage accepts animateEntrance for live arrivals, highlighted for attention feedback, and onAnimationComplete; history defaults to no entrance animation. Motion respects reduced-motion preferences. ChatBubble and ChatMessageLayout have no Motion dependency.
This smaller example uses ChatConversation, ChatMessage, and Composer with useChatScroll, without an event or drawer. Messages use plain string children and the composer has no mention configuration. All messages remain local to this example.
The same chat components can live directly in a page. Try sending a message.
Enter to send / Shift + Enter for a new line
The following is the complete source of apps/design-system/components/chat-ui-inline-example.tsx. It uses the existing workspace package and Coss theme setup. Add it to a client component; replace its local array and synthetic copy when connecting the app.
"use client";
import { ChatConversation } from "@coss/ui/chat/chat-conversation";
import { ChatMessage } from "@coss/ui/chat/chat-message";
import { useChatScroll } from "@coss/ui/chat/hooks/use-chat-scroll";
import { Avatar, AvatarFallback } from "@coss/ui/components/avatar";
import { Button } from "@coss/ui/components/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
type ComposerValue,
getComposerText,
} from "@coss/ui/components/composer";
import { useMediaQuery } from "@coss/ui/hooks/use-media-query";
import { ArrowUpIcon } from "@coss/ui/icons";
import type { ReactElement } from "react";
import { useId, useState } from "react";
export function ChatUiInlineExample(): ReactElement {
const [draft, setDraft] = useState<ComposerValue>([]);
const hintId = useId();
const touchInput = useMediaQuery({ pointer: "coarse" });
const [messages, setMessages] = useState([
{
id: 0,
own: false,
text: "The same chat components can live directly in a page. Try sending a message.",
},
]);
const scroll = useChatScroll({ itemCount: messages.length });
return (
<section aria-label="Inline chat example" className="flex h-96 flex-col rounded-xl border bg-popover">
<ChatConversation scroll={scroll} label="Inline conversation" jumpLabel="Jump to latest">
<div className="space-y-4 pt-4">
{messages.map((message) => (
<ChatMessage
key={message.id}
side={message.own ? "outgoing" : "incoming"}
author="Casey"
avatar={
<Avatar aria-hidden="true" className="size-7 text-xs">
<AvatarFallback>C</AvatarFallback>
</Avatar>
}
animateEntrance={message.own}>
{message.text}
</ChatMessage>
))}
</div>
</ChatConversation>
<div className="flex shrink-0 flex-col gap-2.5 px-4 pb-4">
<Composer
submitOnEnter="desktop"
value={draft}
onValueChange={setDraft}
onSubmit={(value: ComposerValue) => {
const text = getComposerText(value).trim();
if (!text) return;
scroll.atBottomRef.current = true;
setMessages((current) => [...current, { id: current.length, own: true, text }]);
setDraft([]);
}}>
<ComposerInput
aria-describedby={touchInput ? undefined : hintId}
aria-label="Inline message"
placeholder="Write a message…"
render={<textarea />}
/>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
{!touchInput && (
<p id={hintId} className="text-end text-muted-foreground text-xs">
Enter to send / Shift + Enter for a new line
</p>
)}
</div>
</section>
);
}This example deliberately demonstrates local sending only. Its array indexes, own flag, and immediate success are fixtures, not a production message contract. Use stable server IDs, reconcile optimistic messages, and translate labels in the application. Keep the existing Coss stylesheet and Tailwind source discovery; do not install another copy of Base UI or Motion for this composition.
Build the client controller in the owning apps/web/modules feature when it depends on tRPC, authentication, or routing. Domain behavior belongs in the owning service; the Chat UI family receives view data and callbacks. Import family components directly from @coss/ui/chat/*; keep demo fixtures in the design-system app.
Read openspec/specs/cal-events-chat/spec.md for event eligibility and service rules. Use the current API and DTO definitions from the backend work; the names in the following table describe data responsibilities, not new endpoint or DTO names. The demo's CHAT_MEMBERS, MemberId, scenario flags, timers, and receipt fixtures must not become production contracts.
| Application supplies | Component contract | Integration responsibility |
|---|---|---|
| Stable message ID, author, ordered body and timestamp | ChatMessage: React key, side, author, avatar, children, footer | Determine own messages from the authenticated user. Format localized times and group consecutive messages. Use grouped only within the same author/date group. |
| Live arrival versus loaded history | ChatMessage: animateEntrance | Animate a new arrival once; do not animate fetched history or replay the entrance after a refetch. |
| Visible timeline and viewport state | ChatConversation: scroll, label, jumpLabel; useChatScroll | Supply message order, date/unread markers, history pagination, and the policy for acknowledging reads. |
| Draft and send callback | Composer: value, onValueChange, onSubmit; ComposerInput | onSubmit receives a structured draft. Convert it with getComposerText(value).trim() when sending text. Own pending/failed state and retry, and clear the accepted draft. |
| Mention candidates | Composer: triggers; ComposerPopup, ComposerList, ComposerItem | Supply stable IDs and labels for each trigger. Selected mentions remain structured tokens; getComposerText converts them to displayed handles. Resolve authorization and membership in the application. |
| Allowed message actions | ChatMessage: actions; ChatMessageActionToolbar: reply callbacks and menuChildren | Omit unauthorized menu items. There is no built-in canDelete or role system. Recheck permissions on every server mutation. |
| Reaction counts, current-user selection, and reactors | ChatReactionChip and ChatReactionPicker | Pass a boolean selected, supply reactor content, and persist toggles. Counts and lists come from the application. |
| Current members and banned people | ChatMemberList / ChatMemberSection: members with id, name, optional avatar, badges, action, dimmed | Keep membership, mute, and ban separate. Supply authorized controls and fetch the appropriate roster when the nested drawer opens. |
| Per-message recipient state | ChatMessageInfo: message, metadata, groups; ChatDeliveryIndicator | Build mutually exclusive Read by / Delivered to / Pending delivery groups from actual receipt data. Empty groups hide automatically. |
| Reply target and quoted content | ChatReplyReference, ChatReplyPreview, conversation overlay | Persist the reply-to ID. Own source geometry, presence, selection, and focus; see focused reply. |
| Access, history, and connection state | ChatEmptyState, ChatLoadingState, ChatStatusBar | Choose which content and actions are allowed. A disabled composer is not an authorization boundary. |
For receipts, distinguish “some recipients have read” from “everyone has read.” The demo shows blue double checks only when all current recipients have read, gray double checks when all have at least received it, and one check otherwise. Sending/failure state takes precedence. Confirm the recipient set and acknowledgement contract with the backend; never infer delivery or reading from a timer, connected socket, or scroll position alone.
scroll.atBottomRef.current = true before appending your outgoing message. Preserve failed content with a Retry action; reconcile server events with the pending message instead of appending it twice.Deletion must clear all rendered copies of the body: the timeline, reply references, focused reply, and message-info preview. Remove reactions and a matching pin. Retain the message row, author, and time with the correct deletion reason. Do not keep erased text in the client cache or offer an Undo that the backend cannot perform.
This is the intended event-chat presentation. Treat server authorization as authoritative, including if permissions change while a menu is open.
| Action | Sender / guest | Host or accepted co-host |
|---|---|---|
| Delete own message | Allowed | Allowed |
| Delete another guest's message | Not allowed | Allowed, including former or banned guests |
| Delete another host/co-host's message | Not allowed | Not allowed |
| Mute/unmute, remove, or ban a guest | Not allowed | Allowed |
| Moderate another host/co-host | Not allowed | No controls in this demo |
| Unban a guest | Not allowed | Allowed; membership restoration needs the contract below |
Muting blocks sending and typing, while retaining reading and receipt acknowledgement. Removing or banning a member does not delete their historical messages. Only explicit message deletion produces “Message deleted” or “Removed by a host.”
The visual flow is agreed; these backend differences must be resolved before connecting the corresponding controls. The observations below refer to the reviewed PR #9727 head 73423a57beafb97f44c0983879ed08ba354cfd8c and the moderation ticket descriptions reviewed on October 9, 2026. Recheck the implementation before wiring it.
| Topic | Intended UI | Required integration decision |
|---|---|---|
| Remove versus Ban | Remove ends membership and permits an eligible explicit rejoin. Ban also prevents rejoining. | PRO-2343 still calls a ban “Remove.” Agree separate authorized operations and matching copy. Do not wire both buttons to banMember. |
| Unban | Returns the eligible person to Current members, keeping their mute. | The reviewed unbanMember only removes the ban. Coordinate an authorized membership restoration on the server before showing the person as a member; do not restore old receipt watermarks. |
| Persistent mute | Leave/rejoin, removal, and ban/unban do not clear it. | Read mute independently of membership and enforce it for send/typing. Do not derive it solely from the current roster. |
| Message deletion | Sender/moderator placeholders, erased body, no Undo. | PRO-2342 defines authorization, deletion events, and reconnect refresh. A repository method alone does not deliver those application guarantees. |
Use apps/design-system/components/chat-ui-demo.tsx as the shell reference. Port its composition into the owning feature; replace its controller and fixture data. Keep these settings together:
| Part | Existing composition and reason |
|---|---|
| Root | Controlled Coss Drawer, position={useMediaQuery("md") ? "right" : "bottom"}. Retain its default modal behavior and page scroll lock. Keep scenario controls outside product UI. |
| Keyboard | Bottom DrawerPopup provides DrawerPrimitive.VirtualKeyboardProvider automatically. Do not wrap it in another keyboard provider. |
| Popup | variant="inset", default close button, and the drawer's default initial focus behavior. Opening the drawer must not focus the composer or open the software keyboard. |
| Mobile geometry | Main popup uses [--drawer-height:100dvh] and keyboard/safe-area bottom padding; portal viewport uses max-md:[&_[data-slot=drawer-viewport]]:pt-2. Preserve these deliberate keyboard adjustments when porting. |
| Header | Default DrawerHeader, DrawerTitle, and DrawerDescription. Member text opens the nested roster. |
| Panel | DrawerPanel scrollable={false} with relative flex min-h-0 flex-1 flex-col overflow-clip p-0 pt-0!. The conversation owns scrolling, and the panel clips the focused-reply transition. |
| Conversation | ChatConversation inside the panel. Keep min-h-0 through the flex chain; header and composer stay outside its scroll surface. |
| Composer | Sibling after the panel, with px-4 and pb-[max(1rem,env(safe-area-inset-bottom,0px))]. Do not give the whole popup another scrolling container. |
| Tooltips | The complete example supplies a Coss TooltipProvider; preserve it when porting its action toolbar and tooltips. |
Coss styles and the current package dependencies are already part of the Cal workspace. Keep the existing primitives intact. The architecture handoff in openspec/specs/design-system/design/chat-ui.md records intentional overrides and the later Coss distribution work.
The focused reply is an optional composition; it is not enabled by merely rendering ChatReplyPreview. Preserve the existing interaction when implementing the full demo:
bubbleProps.ref, its width, grouping, and expanded state before selecting it. Keep the message/avatar in their original layout slot while the source is lifted.ChatReplyPreview in ChatConversation.overlay, inside AnimatePresence. Supply the same message content and source geometry. Mark the underlying timeline inactive and pause useChatScroll while the overlay owns the viewport.onExitComplete, then clear it and restore scroll/focus. On main-drawer dismissal, wait for onOpenChangeComplete before resetting the reply.See chat-ui-demo-reply-focus.tsx, chat-ui-demo-message.tsx, and the presence/focus callbacks in chat-ui-demo.tsx together. Copying only the animation component omits the lifecycle needed for a stable return transition.
| State | Conversation and composer | Action |
|---|---|---|
| Not joined | Join introduction; no history or composer | Join after eligibility succeeds |
| Joined, no messages | Empty conversation with composer | Send the first message |
| Initial loading | Shared skeleton; disabled composer | Wait for history |
| History error | Error empty state; no composer | Retry history |
| Disconnected | Cached conversation; disabled composer | Reconnect with a pending state, then refocus when permitted |
| Muted | Readable history; replace composer with a mute notice | No send or typing; this own-user view is not yet demonstrated |
| Access lost | Access empty state; no composer | Do not offer rejoin without renewed eligibility |
| Removed | Removed empty state; no composer | Eligible explicit rejoin through the ticket invitation |
| Banned | Banned empty state; no composer | No rejoin while the ban exists |
| Left | Left empty state; no composer | Eligible explicit rejoin |
The demo states are isolated examples, not a precedence resolver. In the app, access denial takes precedence over cached history; a ban takes precedence over a rejoin action; mute takes precedence over enabling send after reconnect. Dispose of old-room subscriptions and pending callbacks when switching rooms.
Delivery indicators expose a labelled image, not a live region. Keep important send failures in one application-owned status region; do not announce the same failure from each receipt. Supply labels for sending, sent, delivered, read, and failed states.
Message menus and reactors popovers dismiss on deliberate input scrolling their conversation. Automatic scrolling, incoming messages, menu keyboard navigation, and scrolling unrelated containers must keep them open. Reaction inspection keeps the 500ms touch hold and 8px movement tolerance; a short tap still toggles the reaction. Preserve reduced-motion behavior.
useChatScroll active only for the visible conversation, pause it for focused/nested UI, and reset it only when changing rooms. Implement older-history anchoring separately: capture the viewport before prepending and preserve the visible message afterward. The hook does not fetch, paginate, virtualize, or compensate for prepended pages.onReachEnd only reports scrolling near the bottom; it is not proof the user read every message. Confirm the recipient set after membership changes.Paths below are relative to the Cal repository. Import reusable components from the package; consult demo files for composition without introducing a dependency on apps/design-system.
| Source | Read it for |
|---|---|
packages/coss-ui/src/chat/chat-*.tsx | Public prop contracts and rendering behavior |
packages/coss-ui/src/chat/hooks/use-chat-scroll.ts | End-following, pause/reset, viewport refs |
apps/design-system/components/chat-ui-inline-example.tsx | Minimal local composition, reproduced above |
apps/design-system/components/chat-ui-demo.tsx | Main/nested drawers, membership controls, confirmations, focused-reply lifecycle |
apps/design-system/components/chat-ui-demo-message.tsx | Message slots, action permissions, quote/deletion presentation |
apps/design-system/components/chat-ui-demo-reply-focus.tsx | Focused reply adapter and source geometry |
apps/design-system/components/chat-ui-demo-message-info.tsx | Nested receipts drawer and message preview |
apps/design-system/components/chat-ui-demo-scenarios.tsx | Empty, loading, access, and recovery presentations |
apps/design-system/components/chat-ui-demo-state.ts, chat-ui-demo-receipts.ts | Demo policy and synthetic data only; do not copy as backend logic |
openspec/specs/cal-events-chat/spec.md | Event-chat eligibility and domain contract |
openspec/specs/design-system/spec.md | Accepted Chat UI behavior and documentation requirements |
openspec/specs/design-system/design/chat-ui.md | Family ownership, primitive overrides, and Coss rebuild boundary |
The drawer has a header, a scrolling message timeline, and a composer. The middle is a repeater, so the map labels the components that can appear inside it. It combines conditional states and actions for reference; they do not all appear at once.
Message timeline · repeating message rows
Deleted message placeholderTyping indicatorEmpty / error / reconnect / access lostvisible prop to animate its exit. Reduced motion disables these animations.@, filtered from the supplied options, with avatars, names, and keyboard selection. No popup appears when no members match. Loading and empty-state presentations are not implemented.The dashboard may need discovery across chats. The following map is an exploration inventory; working conversation-list, search, and list-state components are not implemented in this example or exported by the Chat UI family.
Conversation list · repeating list items
Conversation list itemLoading / empty / no results / errorThe anatomy maps also show exploratory components and states that are not part of the working example. They do not decide package ownership or commit every feature to the first product release.
On This Page