Agent UI
Architecture, contracts, and examples for Cal's reusable AI agent interface.
Agent UI is the presentation layer for Cal's global AI experience. It provides composable conversation, activity, approval, history, and input parts instead of one fixed chat implementation.
These components are a parallel prototype. Existing feature-specific AI surfaces keep their current implementations; the global agent will be the first product integration for this layer.
Generated-image slots are a different problem. Use AI image placeholder when a media frame is waiting on an AI image. That control is not a conversation part.
Architecture
Ownership boundary
| Layer | Location | Owns |
|---|---|---|
| UI | @calcom/ai/react/ui/* | Markup, styling, accessibility, local disclosure, clipboard and feedback state, and plain callbacks |
| Adapters | @calcom/ai/react/adapters/* | AI SDK state and localized-label mapping into UI props |
| Product surface | Future global-agent integration | Messages, sessions, routing, persistence, permissions, tools, page context, queues, and transitions |
The design-system examples import only from @calcom/ai/react/ui/* and supply mock state locally. Every file under that path is covered by the package wildcard export, so a new UI module does not require another package.json export.
Component map
There are eighteen consumer-facing presentation modules and three shared implementation modules. Their parts remain independently useful: a compact surface may omit timestamps, feedback, mentions, or history.
| Area | Module | Main exports |
|---|---|---|
| Launcher and sessions | @calcom/ai/react/ui/agent-action-bar | AgentActionBar, AgentActionBarSession, AgentActionBarLauncher |
| Panel | @calcom/ai/react/ui/agent-panel | AgentPanel |
| Mobile panel | @calcom/ai/react/ui/agent-mobile-sheet | AgentMobileSheet |
| Decoration | @calcom/ai/react/ui/agent-orb | AgentOrb |
| Conversation | @calcom/ai/react/ui/agent-conversation | AgentConversation, AgentConversationScrollButton, AgentMessage, and timestamp parts |
| Conversation | @calcom/ai/react/ui/agent-conversation-menu | AgentConversationMenu |
| Message controls | @calcom/ai/react/ui/agent-message-actions | AgentMessageActions, AgentMessageCopyAction, AgentMessageEditAction |
| Message controls | @calcom/ai/react/ui/agent-response-feedback | AgentResponseFeedback |
| Agent activity | @calcom/ai/react/ui/agent-reasoning | AgentReasoning |
| Agent activity | @calcom/ai/react/ui/agent-status-indicator | AgentStatusIndicator |
| Agent activity | @calcom/ai/react/ui/agent-tool-activity | AgentToolActivityGroup, AgentToolActivityRow |
| Result lists | @calcom/ai/react/ui/agent-result-list | AgentResultList |
| Internal | @calcom/ai/react/ui/agent-disclosure-trigger | Shared disclosure trigger used by reasoning and tool activity; consumers should use those modules instead. |
| Internal | @calcom/ai/react/ui/agent-morph-loader | Animated dot-grid loader shown for the thinking and working statuses; consumers should render AgentStatusIndicator instead. |
| Decisions | @calcom/ai/react/ui/agent-approval-card | AgentApprovalCard, AgentApprovalGroupCard |
| Decisions | @calcom/ai/react/ui/agent-approval-pending | AgentApprovalPending |
| Notices | @calcom/ai/react/ui/agent-notice-card | AgentNoticeCard |
| Input | @calcom/ai/react/ui/agent-composer | AgentComposer and the flat AgentComposer* parts |
| Input with mentions | @calcom/ai/react/ui/agent-mention-composer | AgentMentionComposer, structured value types, and getComposerText |
| History | @calcom/ai/react/ui/agent-history-popover | AgentHistoryPopover |
AgentOrb (@calcom/ai/react/ui/agent-orb) supports composer and title placements.
Choose an entry point
| Need | Use | Owns |
|---|---|---|
Connect an existing AgentChat | @calcom/ai/react/adapters/agent-chat-composer | Maps input, busy, approval, submit, stop, and localized action labels. It does not queue while a request is busy. |
| Render AI SDK approvals or tool parts | The matching @calcom/ai/react/adapters/* module | Converts runtime data and localized labels into UI props. |
| Build product-owned behavior | The matching @calcom/ai/react/ui/* module | Renders controlled state and reports intent through plain callbacks. |
| Host action-bar sessions in one panel | @calcom/ai/react/ui/agent-panel with Coss Popover | Anchors one shared panel to stable session triggers; the product owns the active payload and session state. |
Use an adapter when it prevents repeated runtime or localization mapping. Routing, persistence, permissions, tool side effects, and message-list mutations stay in the product surface. Result-list row mapping stays in the product surface too โ AgentResultList only renders items.
UI reference
All focused examples use local mock state. They do not construct AgentChat, call an AI model, persist a conversation, or perform a mutation.
Conversation
AgentConversation places an existing transcript at its latest message before the first paint. It follows later content only while the reader is already at the latest message; if the reader scrolls upward, it preserves that position and can show an accessible jump-to-latest control.
| Component | Required props | Important options |
|---|---|---|
AgentConversation | โ | children supplies the transcript. contentClassName styles the message stack. scrollToLatestLabel enables the automatic jump control. scrollButtonClassName aligns that control on a full-width overlay; the default is the bottom-right of the scroll area. A full-page host can reuse the same max-w as the message column. |
AgentConversationScrollButton | label | Accepts the remaining Coss Button props for manual composition. |
AgentMessage | role: "assistant" | "user" | children supplies the message; actions provides the hover and keyboard-focus action slot. Set actionsAlwaysVisible on the final completed response to keep its actions visible in the document flow and reserve space above the composer. Native div props are forwarded. |
AgentMessageText | children | Plain wrapper for prompt and response prose. Always shows the full content โ never a Show more / Show less preview. |
AgentConversationTimestamp | โ | Optional centered transcript divider. Native time props, including children, are forwarded. |
AgentMessageTimestamp | โ | Optional message-level timestamp. Native time props, including children, are forwarded. |
Messages wrap long unbroken text and cap their width at 48rem; user bubbles also stay within 85% of their container. Compose AgentMessageText around text only, leaving reasoning, tool activity, result lists, approvals, and actions outside. Agent response prose is never height-clamped or disclosed; readers scroll the conversation viewport to read longer replies. AgentDisclosureTrigger chrome is reserved for AgentReasoning and tool activity groups. Long result lists use a remainder toggle, not that chrome.
Select Add long messages to exercise scroll behavior with tall prompt and response content. Scroll away from the latest message and add an update to verify that the viewport preserves the reader's position. This focused example includes the optional timestamp divider; the complete prototype intentionally omits timestamps for the initial experience.
Conversation menu
The dedicated chat title and the action-bar panel can host the same overflow actions. The UI owns the menu, clipboard copy, and delete confirmation. The product supplies the markdown string and performs the delete.
| Component | Required props | Important options |
|---|---|---|
AgentConversationMenu | labels, markdown, onDelete | Icon-only ellipsis trigger with a tooltip from labels.menu. Copy writes markdown to the clipboard and announces labels.copiedAsMarkdown or labels.copyFailed. Delete opens an AlertDialog; confirm reports onDelete without mutating sessions or routes. |
Message actions
Message actions appear when their parent message is hovered or contains keyboard focus. Copy owns only its brief clipboard feedback. Edit reports intent because the product owns the transcript branch.
| Message state | Actions |
|---|---|
| User prompt without attached context | Copy and Edit |
| User prompt with attached context | None โ the context belongs to the submitted turn. |
| Completed assistant response | Good response and Bad response feedback |
| Streaming, stopped, or failed response; unresolved approval | None โ feedback is available only for a completed response. |
| Component | Required props | Important options |
|---|---|---|
AgentMessageActions | label, children | Creates the accessible toolbar. Native div props are forwarded. |
AgentMessageCopyAction | text, copyLabel, copiedLabel | Uses the shared clipboard hook and swaps its icon and accessible label after copying. |
AgentMessageEditAction | editLabel, onEdit | Reports the edit request without mutating messages or the composer. |
When editing, keep every message before the selected prompt, move that prompt into the composer, remove the selected prompt plus every later message, and return focus to the composer:
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0) return;
composerHostRef.current
?.querySelector<HTMLTextAreaElement>('[data-slot="composer-input"]')
?.focus();
setComposerValue(messages[index].text);
setMessages(messages.slice(0, index));Context-bearing messages omit copy and edit actions because the attached context is part of the submitted turn.
Response feedback
Completed agent responses can compose AgentResponseFeedback inside the same AgentMessageActions toolbar. It renders Good response and Bad response icon actions with tooltips. Good response submits immediately; Bad response opens a Coss dialog for one or more caller-supplied reasons and optional details. Clicking the selected rating removes it through onFeedback(null). A successful rating callback shows the supplied thank-you toast. A rejected callback shows the supplied error toast and keeps the negative-feedback dialog open.
| Prop | Required | Contract |
|---|---|---|
labels | Yes | Supplies localized action names, dialog copy, form labels, and success/error toast text. |
reasons | Yes | Supplies stable values and localized labels for the multi-select reason controls. |
onFeedback | Yes | Receives { rating, reasons, details }, or null to remove a rating, and may return a promise. The initiating button shows a loading indicator and controls prevent another submission while it is pending. Persistence and analytics stay with the product. |
value | No | Controls the selected "positive", "negative", or null state. |
defaultValue | No | Initializes the selected state when the component is uncontrolled. |
disabled | No | Disables both feedback actions. |
Keep feedback attached to the final assistant message rather than streaming reasoning, tool rows, or approval prompts. Close over the product-owned message ID when persisting it:
<AgentMessage
role="assistant"
actionsAlwaysVisible={messageIsComplete && messageIsLatest}
actions={
messageIsComplete ? (
<AgentMessageActions label={labels.responseActions}>
<AgentResponseFeedback
labels={labels.feedback}
reasons={feedbackReasons}
onFeedback={(feedback) => saveFeedback(message.id, feedback)}
/>
</AgentMessageActions>
) : undefined
}
>
{messageContent}
</AgentMessage>The focused message-actions example demonstrates user Copy and Edit actions plus completed-response feedback. The complete prototype applies feedback to completed responses, keeps the final completed response's actions visible, and omits feedback while a response is still streaming.
Agent activity
Text and reasoning content arrive from the AI SDK or the product caller. CSS handles only presentation such as shimmer, disclosure, and reduced-motion behavior.
Reasoning
Disclosure for agent thinking and reasoning lives here โ not on response prose. Use this collapsible surface (and tool activity groups) whenever content should be inspected on demand; never put Show more / Show less on AgentMessageText.
| Required props | Important options | Behavior |
|---|---|---|
children, completedLabel, streamingLabel | isStreaming, defaultOpen, contentClassName | Opens while streaming unless explicitly opted out, then closes one second after streaming ends. A person's disclosure choice cancels the pending automatic close. The trigger label updates immediately from streamingLabel to completedLabel. |
Status indicator
| Required props | Important options | Behavior |
|---|---|---|
label, status | announce defaults to true; native span props are forwarded | Supports idle, thinking, working, needs-input, complete, and error. The first label appears immediately, with shimmer for active states. Later label and icon changes appear immediately without text animation. Active shimmer respects reduced motion. |
thinking and working share an animated dot-grid icon that keeps running when switching between these states. Status changes keep the icon and label aligned. Tool activity summaries use muted to keep both neutral.
Labels and icons appear immediately on first render and on every update, without text morphing. Active labels shimmer. Under reduced motion, the icon stays static and labels update immediately without shimmer. The full label remains visible and available to screen readers. All status variants wrap within their available width and grow vertically, including during active shimmer and under reduced motion. Icons and disclosure carets align with the first line. The constrained-width example below exercises long labels, including the complete out-of-credits recovery instruction.
Tool activity
| Component | Required props | Important options |
|---|---|---|
AgentToolActivityGroup | children, isRunning, summary: string | hasError marks the summary as an error while keeping the group collapsed by default. The trigger uses the same disclosure chrome and status indicator as reasoning; completed and failed summaries stay muted. The group itself does not derive a summary from its rows. |
AgentToolActivityRow | children | Icon replaces the default activity icon. |
The runtime adapter maps tool-part state to these presentation states:
| Runtime state | Summary and row treatment |
|---|---|
| Running or incomplete | Running count, spinner, and a shimmering action label |
output-available | Completed count and the configured output renderer or action label |
output-denied | Declined count and a struck-through action label followed by โdeclinedโ |
output-error | Failed count; opening the group reveals the error explanation |
Skip is the approval action shown before a tool runs. Declined is the transcript result after the SDK records output-denied; they describe different moments of the same decision.
Result lists
AgentResultList turns a structured tool page into a compact card of one-line rows. Use it when the reader should scan and open items. Do not ask the model to restate those items as a markdown table, numbered list, or bullet list.
The list is domain-agnostic. Each skill maps its own summary fields onto this row. The product still renders list tools as model markdown; this card is the composition to reuse when that mapping lands. Event types, teams, members, schedules, routing forms, workflows, and later list tools should share this card, not invent a second one.
import { AgentResultList } from "@calcom/ai/react/ui/agent-result-list";| Prop / item field | Type | Notes |
|---|---|---|
showMoreLabel, showLessLabel | string | Remainder toggle. A page of more than six rows shows the first five and this control. |
items | AgentResultItem[] | One row per result. An empty page renders nothing โ the sentence above the list is enough. |
id, title | string | Required row identity and primary text. |
subtitle | string? | One secondary fact after the title: a time, slug, username, time zone, team type, or trigger. |
href | string? | Makes the row a link to that item in the app. Omit it when the product has no item page. |
label | string? | Accessible name for the link. |
secondaryBadge | string? | Extra right-side metadata, such as Recurring. |
badge, badgeVariant | string?, Coss Badge variant | Right-side status, role, duration, or similar. Soft warning / error for pending and cancelled bookings. Roles and durations use the default badge. |
Each row is one line: title, then the subtitle, then badges on the right. Hover matches ListItem. The card uses rounded-lg, including the before radius. A short sentence sits above the card, not below it. More than six rows keep the first five visible and put the rest behind Show N more / Show less, the same remainder rule as skipped invite addresses. A leftover of one stays expanded. Both controls prepend their icon: ChevronsUpDownIcon for Show more, ChevronsDownUpIcon for Show less. That toggle is not AgentDisclosureTrigger. The remainder starts closed again when the item ids change. A host that keeps one instance across result pages can also key it by that page.
The switcher maps each list tool onto the same row. Members is an 11-row page so the remainder toggle is visible. Team members have no item href in the app, so those rows are not links. Empty is a sentence with no card.
| Example | Tool | Title | Subtitle | Badge |
|---|---|---|---|---|
| Bookings | get_bookings | Booking title | Local start | Pending / Recurring when needed |
| Event types | list_event_types | Event type title | Slug | Length |
| Teams | list_teams | Team or org name | Team or Organization | The viewer's role |
| Members | list_team_members | Name | Username | Role |
| Schedules | list_schedules | Schedule name | Time zone | Default when it is the default |
| Routing forms | list_forms | Form name | โ | โ |
| Workflows | get_workflows | Workflow name | Trigger | Team name when it is a team workflow |
const { hidden } = splitAgentResultItems(items);
<AgentResultList
items={items}
showLessLabel="Show less"
showMoreLabel={`Show ${hidden.length} more`}
/>Call sites
| File | Notes |
|---|---|
packages/ai/src/react/ui/agent-result-list.tsx | Presentation list |
apps/design-system/components/agent-result-list-example.tsx | Domain prototypes |
Approvals
Approval cards report decisions through callbacks but never execute the mutation. An empty grouped approval list renders nothing.
After the user clicks Approve or Skip, the card must not vanish into a blank gap. Wrap it in AgentApprovalPending so the card scales down from bottom-center, blurs, and fades into a Workingโฆ status while the agent resumes. The product owns the phase (awaiting โ pending) and clears it when the next visible reply arrives or the action fails. Disable the card while pending so the decision cannot be submitted twice.
| Component | Required props | Important options |
|---|---|---|
AgentApprovalCard | labels, prompt, toolName, onRespond | disabled prevents either decision while the product is processing it. |
AgentApprovalGroupCard | items, labels, onRespond | disabled prevents bulk decisions. Shared questions and confirm labels collapse into one group heading and action. |
AgentApprovalPending | children, pendingLabel, phase | phase: "awaiting" | "pending". Optional pendingStatus defaults to working; announce defaults to true. CSS-only exit: bottom-center scale, blur, and fade. |
AgentApprovalPrompt requires question and can add questionForMany, context, details, chip, and per-prompt confirm or cancel labels. A grouped AgentApprovalItem adds approvalId and toolName.
import { AgentApprovalPending } from "@calcom/ai/react/ui/agent-approval-pending";
<AgentApprovalPending pendingLabel="Workingโฆ" phase={phase}>
<AgentApprovalCard
disabled={phase === "pending"}
labels={labels}
onRespond={(approved) => {
setPhase("pending");
respond(approved);
}}
prompt={prompt}
toolName="create-event-type"
/>
</AgentApprovalPending>AgentChatTranscript already wires this handoff: answered approvals stay mounted while the turn is busy, the card transitions into Workingโฆ, and the bottom thinking indicator stays quiet so feedback is not duplicated.
Pending feedback after decision
Approve or skip to see the card scale down from bottom-center, blur, and hand off to Workingโฆ
Apply changes to this routing form?
- Added company size
- Updated the enterprise route
Grouped approval
Create the missing event types?
- Customer interview30 min, collective
- Product feedback45 min, round robin
Skip first to request changes
Choose an approval action to see the reported intent.
Notices
A notice is a stop the person acts on outside the conversation. It uses the approval cardโs frame and none of its machinery: no Approve or Skip, and nothing is reported back to the agent. Use an approval card when the agent is waiting on a decision. Use a notice when the turn has already stopped and the next step lives somewhere else, such as billing.
AgentNoticeCard takes a title and description, plus an optional icon, footer action, and hint. The action and the hint are both optional, because the caller may have nothing to offer.
Youโre out of credits
Add credits to keep using Cal.ai.
Composer
AgentComposer composes the shared Coss Composer. It uses a native textarea for plain text; AgentMentionComposer uses the same primitiveโs structured-token input. The agent layer adds page context, pointer glow, queues and Send/Stop mapping. The caller owns the value and every state transition.
| Prop | Required | Contract |
|---|---|---|
value, onValueChange | Yes | Controlled textarea value and change callback. |
onSubmit, onStop | Yes | Intent callbacks. An empty submit focuses the textarea and does not call onSubmit. |
inputLabel, placeholder | Yes | Accessible name and visible empty-state copy. |
sendLabel, stopLabel | Yes | Accessible names and tooltips for the primary action. |
isBusy | No | Replaces Send with Stop. |
allowSubmitWhileBusy | No | When enabled, a non-empty value shows Send while busy so the product can queue or interrupt. The composer does not store a queue. |
disabled | No | Disables the textarea and Send, and keeps the pointer-follow border glow off. Caller-supplied leading and trailing actions must be disabled or omitted separately when needed. |
autoFocus, autoComplete, name, rows | No | Input behavior; name produces a hidden field containing readable text, and rows sets the minimum visible rows. Set autoFocus={false} when the composer is secondary content or documentation. |
header, leadingActions, trailingActions, footer, hint | No | Composition slots for product-owned content and actions. header is the framed page-context label above the input. |
queued | No | Stacks caller-owned queued prompts above the composer, outside the textarea. Use AgentComposerQueuedMessage for each prompt. New prompts append below existing ones. The composer does not store a queue. |
inputStart, inputStartClassName | No | Decoration before the textarea, such as AgentOrb on the full-page pill. |
*ClassName props | No | Target the corresponding surface without replacing component structure. textareaScrollAreaClassName sizes the Coss ScrollArea that clips the growing textarea (default max-h-48). That viewport uses the same scrollFade mask as other Coss scroll areas. |
The convenience component and composable parts share the same controlled root contract. Like Coss UI, every part is a flat named export. Use the parts only when the standard slot layout is not sufficient:
| Component part | Purpose |
|---|---|
AgentComposerRoot | Provides controlled state and callbacks to every nested part. |
AgentComposerFrame | Wraps the input and optional footer treatment. |
AgentComposerInput | Renders Coss Composer with the agent state and styling. |
AgentComposerTextarea | Renders ComposerInput as a native textarea with data-slot="composer-input" for host focus management. |
AgentComposerHeader | Holds the framed page-context label above the input. |
AgentComposerQueued | Stacks queued prompts above the composer. |
AgentComposerActions | Composes ComposerActions and the tooltip provider. |
AgentComposerLeading | Holds caller-supplied leading actions. |
AgentComposerTrailing | Holds caller-supplied trailing actions and Submit. |
AgentComposerSubmit | Chooses Coss ComposerSubmit or Stop from root state. |
AgentComposerFooter | Holds content below the input inside the frame. |
AgentComposerHint | Renders supporting text below the frame. |
AgentComposerQueuedMessage | Displays one caller-owned queued prompt with optional send and remove callbacks. The row has no background until hover. Send and remove reuse AgentIconAction. |
AgentIconAction | Ghost icon-xs tooltip button used by panel header actions (minimize, expand, close) and queued-message send/remove. |
import {
AgentComposerActions,
AgentComposerFrame,
AgentComposerInput,
AgentComposerLeading,
AgentComposerQueued,
AgentComposerRoot,
AgentComposerSubmit,
AgentComposerTextarea,
AgentComposerTrailing,
} from "@calcom/ai/react/ui/agent-composer";
<AgentComposerRoot
inputLabel="Message Cal"
onStop={stop}
onSubmit={submit}
onValueChange={setInput}
placeholder="Ask Calโฆ"
sendLabel="Send"
stopLabel="Stop"
value={input}
>
<AgentComposerQueued>{queuedPrompts}</AgentComposerQueued>
<AgentComposerFrame>
<AgentComposerInput>
<AgentComposerTextarea />
<AgentComposerActions>
<AgentComposerLeading>{pageContextAction}</AgentComposerLeading>
<AgentComposerTrailing>
{attachmentAction}
<AgentComposerSubmit />
</AgentComposerTrailing>
</AgentComposerActions>
</AgentComposerInput>
</AgentComposerFrame>
</AgentComposerRoot>The textarea uses Enter to report submit and Shift+Enter to insert a new line. The focused example covers new-chat, busy, queued, and disabled states. In Busy or Queued, type a prompt and press Enter to append it below any existing queued prompts. Each queued row stays transparent until hover. The composer does not store a queue itself.
Page context
Page context may attach the current page to the first prompt of a new chat. The product surface owns the page identity and records it with the submitted message.
| State | Reference behavior |
|---|---|
| Empty transcript, context off | Show one circular box action with โUse this page as context.โ |
| Empty transcript, context on | Replace it with the page name and one remove button. |
| First prompt submitted | Hide the composer action and show the attached page name below the user message. Do not show copy or edit actions. |
| Transcript already started | Do not offer page context. |
Use layout="adaptive" with rows={1} for the full-page pill: Coss measures the real input, moves controls below multiline drafts, and handles external draft replacements and resizing. Panel compositions use the stacked layout.
Leading and trailing actions are caller-supplied. The examples provide reference page-context and attachment actions; they are not public AgentComposer parts because choosing a page or uploading a file is product behavior.
Mentions and slash actions
Use AgentMentionComposer when a message needs non-editable references and AgentComposer for plain text. Both use Coss Composer; the agent composition has no Lexical dependency. Calโs adapter translates scoped references into serializable token data and an explicit text representation, preserving membership IDs, scope and sent-text offsets. Suggestion fetching and authorization stay with the product.
Nothing sent. This example runs locally.
Value and ownership
The value is an array of text and mention parts, not Lexical state:
import type { ComposerValue } from "@calcom/ai/react/ui/agent-mention-composer";
const value: ComposerValue = [
{ type: "text", text: "Ask " },
{ type: "mention", trigger: "@", id: "person-alex", label: "Alex Morgan" },
{ type: "text", text: " about Friday." },
];getComposerText(value) produces the readable message; preserve the parts separately when IDs matter. References from the editor or clipboard are untrusted input. The server must resolve IDs and check access before fetching context or invoking tools. The UI never searches a backend or grants access.
| Prop | Contract |
|---|---|
value, onValueChange | Controlled ComposerValue. Echo edits synchronously; debounce persistence, not the editor value. Use [] to clear. External replacements reset undo history. |
triggers | Single punctuation characters, such as ["@", "/"]. A trigger starts after whitespace or at the start of text. Queries accept letters, numbers, spaces, dots, _ and -, up to 64 characters. |
onQueryChange | Receives { key, trigger, text } or null. Filter locally or fetch authorized results; cancel outdated requests in the consumer. |
suggestions | { queryKey, status, items } or null. Echo the request's key, never the newest key for an older response. Mismatched results are not selectable. Status is ready, loading or error; an empty ready list means no matches. |
items | Unique stable id, visible label, optional description, and kind: "mention" or "command". Optional avatar: { src?, fallback } or a decorative icon React node helps distinguish results; avatar takes precedence. These visuals are not stored in the draft. Return a bounded result list. |
onCommand | Called after a command query is removed. The consumer decides what happens; selecting / never automatically sends or executes a tool. |
items[].group | Optional { id, label } with a stable group ID and localized label. Keep each group's items adjacent; display and keyboard order follow the supplied array. Groups without matching items disappear. Group metadata is not stored in mentions. |
items[].entityType | Optional semantic entity type. "event-type" persists in the value and clipboard, renders with a wrapping muted background and decorative link icon, and omits the query trigger from readable text. Presentation-only suggestion metadata is never stored. |
suggestionLabels | Localized list label, loading/empty/error messages, and results(count) announcement. Supply translations from the product. |
Send/Stop, busy and disabled state, headers, footers and action slots follow the composer contract. name, when provided, submits plain text through a hidden form field; structured references remain caller-owned. An empty send restores editor focus. Existing AgentChatComposer is unchanged and does not accept structured values.
Editing and accessibility
The input stays a labelled multiline textbox, with an associated listbox and active-option announcement. The popup uses coss Popover, not a menu that moves focus away from the editor.
| Interaction | Behavior |
|---|---|
| Up / Down while suggestions are open | Move the active result without moving the text caret. |
| Enter | Select a ready active result; otherwise submit the current prompt when enabled. Loading, stale, empty and error results cannot select an old reference. Busy and disabled states block submission. |
| Shift + Enter | Insert a newline. |
| Escape | Dismiss until the query or caret changes. |
| Tab | Dismiss and move focus normally; never silently select a result. |
| Backspace / Delete next to a mention | Remove the whole token, not part of its label. Undo and redo preserve the reference. |
| Copy / paste | Preserve tokens between compatible composers. External text and HTML paste as plain text; pasted display names do not become mentions. |
| IME composition | Preserve text composition without selecting a result or submitting. |
Suggestions follow the caret query even when editing in the middle of a message. Email addresses, punctuation adjacent to a trigger, selected ranges and token interiors do not open suggestions. The popup uses the same default Coss ComposerPopup as Chat UI: it spans the composer width and anchors above the composer. Results are bound to the reported query key; empty and limit messages remain visible. Focus stays in the editor.
History
History is a searchable, newest-first view of caller-supplied conversations. The caller owns fetching, persistence, active and unread state, date formatting, and selection.
| Prop | Required | Contract |
|---|---|---|
items | Yes | Each item supplies id, title, and updatedAt; active, unread, and metadata are optional presentation data. |
labels | Yes | Supplies every visible and accessible string, including date formatting and item labels. |
trigger | Yes | A Coss button element used as the popover trigger. |
onSelect, onStartChat | Yes | Report selection or empty-state CTA intent without changing product state. |
open, onOpenChange | No | Control the popover when the product needs to coordinate it. |
align, side | No | Positions the popup relative to the trigger. Defaults are end and top for the action-bar control. A page-title trigger typically uses start and bottom. |
showStartChat | No | When true, the first option is labels.startChat with a separator before history. Use this on the page-title combobox for an existing conversation; omit it on a new chat and on the action-bar history control. |
showTriggerTooltip | No | When true (the default), the trigger shows labels.history in a tooltip. Set false for a labeled trigger such as the page title. |
className | No | Styles the popover surface. |
Open the history trigger to search or select a conversation. Toggle the supplied list off to inspect the empty state.
Current: Make test for AI agent launcher
Action bar
| Component | Required props | Contract |
|---|---|---|
AgentActionBar | aria-label | Creates the sessions toolbar and forwards native div props. |
AgentActionBarSession | label, closeLabel, closeOtherSessionsLabel, onClick, onClose, onCloseOtherSessions | Renders a truncated session trigger with hover-close and context-menu actions. Optional indicatorLabel adds a labeled circular status indicator. |
AgentActionBarLauncher | onClick, children | Opens a new agent conversation. |
Set the optional indicatorLabel on a session to show a circular status indicator. The label is visually hidden but included in the button's accessible name; use it to explain the state, such as โNew activityโ, rather than relying on the dot alone. The product owns its lifecycle and should clear it when the activity is read.
Hover a previous-conversation trigger to reveal its close action. Right-click it for session-management actions.
With a keyboard, focusing the session trigger keeps its close action hidden; tab to the close action itself to reveal it.
Link pull request to Base UI issue
Previous chat opened
Panel
AgentPanel is the chat frame: title, header actions, conversation slot, and optional composer. It does not own open state, sessions, or routing. Host it in a Coss Popover when launching from the action bar. Floating hosts pass resizable so size and edge handles live in this shared component โ not in each product or docs wrapper.
| Prop | Required | Contract |
|---|---|---|
title | Yes | Visible heading and accessible name. |
labels | Yes | close, minimize, and openFullPage names for the header actions, plus resizeWidth and resizeHeight labels for resize handles. |
onMinimize | Yes | Reports dismiss intent without changing the current session or transcript. |
onClose | Yes | Reports close intent. The panel does not remove the session; the host decides whether to do so. |
openFullPageHref | No | Turns the full-page control into a link to the dedicated chat page. Without a destination or onOpenFullPage, the control remains visible but unavailable. |
onOpenFullPage | No | Reports expand intent. The dedicated chat page uses this to route to /playground/ai-agents/agent/{uid}. |
resizable | No | When true, the panel owns width/height (default 474ร480) with left and top resize handles. The handles are keyboard-operable with arrows (Shift for larger steps) and Home/End. Width clamps to 400โ580px; height grows upward and clamps to the viewport. Fullscreen hosts leave this off so the panel fills its parent. |
defaultSize | No | Optional { width, height } seed when resizable. |
onSizeChange | No | Reports { width, height, widthDelta, heightDelta } on every applied resize so hosts can keep a right edge fixed while anchoring. |
onResizeStart / onResizeEnd | No | Optional drag lifecycle hooks (for example to pause position springs). |
children | No | Conversation or other body content. |
composer | No | Composer or footer slot. Omit it for a read-only transcript. |
titleIcon | No | Decoration before the title, such as AgentOrb. |
titleActions | No | Optional controls immediately after the title, such as AgentConversationMenu. |
className | No | Styles the frame. The popover host already supplies surface chrome. |
Host contract
When several action-bar triggers share one panel, attach every detached PopoverTrigger to one PopoverCreateHandle and give each trigger a stable session ID as its payload. Keep conversation state keyed by ID and render one Popover so Base UI can animate the popup position (transition on top/left/transform), size, and content without closing it between triggers โ same Linear-style pin-to-pill motion as the product floating toolbar. End-align the popup with the active trigger (align="end"). Pass resizable on AgentPanel so left/top resize cascades everywhere the shared panel is used; do not reimplement handles in the host. The active conversation may change independently from the active anchor.
- The Agent launcher always creates a clean draft; it does not reuse the previous new-chat state.
- Submitting the first prompt creates and highlights a session immediately before the launcher while the open panel stays anchored to the launcher. Selecting that session later moves the panel to its trigger.
- Minimize dismisses the panel but keeps its session. Close reports intent for the host to remove that session.
- History selection opens or reactivates state by conversation ID without removing other sessions.
- Unread state is shared: a history row and its action-bar session show the same indicator. Opening that conversation in the panel or on the dedicated page clears both.
const [panelHandle] = useState(() => PopoverCreateHandle<string>());
const popupRef = useRef<HTMLDivElement>(null);
<PopoverTrigger
handle={panelHandle}
id={`agent-session-${session.id}`}
payload={session.id}
render={<AgentActionBarSession {...sessionActionProps} />}
/>
<Popover<string> handle={panelHandle}>
{({ payload }) => {
if (!payload) return null;
const contentId = payload === NEW_CHAT_ID ? activeChatId : payload;
const sessionState = sessionsById[contentId];
return (
<PopoverPopup
align="end"
className="[&>[data-slot=popover-viewport]]:p-0 [&>[data-slot=popover-viewport]]:[--viewport-inline-padding:0px]"
initialFocus={() =>
popupRef.current?.querySelector<HTMLTextAreaElement>(
'[data-slot="composer-input"]'
) ?? false
}
ref={popupRef}
side="top"
sideOffset={8}
>
<AgentPanel {...panelProps} resizable title={sessionState.title}>
<AgentConversation>{sessionState.messages}</AgentConversation>
</AgentPanel>
</PopoverPopup>
);
}}
</Popover>During a Coss popover crossfade, AgentConversation uses Base UI's data-previous state and its recorded --agent-conversation-scroll-top value to keep the outgoing transcript at the same visible position until it fades. Outside that host, the selector is a no-op.
The Complete prototype demonstrates the full host contract, including initial composer focus and distinct state for every session ID. The playground agent page is the dedicated full-page host for the same contract. A conversation lives at /playground/ai-agents/agent/{uid}. The title history trigger changes that page conversation; the action-bar history trigger still opens a panel session.
Mobile bottom drawer
Below the web app's md breakpoint (800px), the agent opens in a bottom drawer over the current page. This keeps that page mounted so its ViewContext remains available. The existing component is named AgentMobileSheet; it composes Coss Drawer.
The presentation is shared through @calcom/ai/react/ui/agent-mobile-sheet. The web caller supplies translated labels directly and owns the conversation, draft, page context, and navigation.
Messages stay in this example. No model is called.
The example is available at every viewport width so it can be inspected in a narrow browser panel. Open an empty drawer or a sample conversation, type @ for synthetic members, and enter enough text to scroll the composer. Close and reopen it to check that the draft remains. Send appends the message locally. The expand button reports its callback below the example; it does not navigate or call a model.
Anatomy and layout
- A modal backdrop covers the current page.
- The drawer occupies approximately 95% of the viewport, with rounded top corners and a fixed header.
- The header contains the agent orb, a truncated conversation title, Open full-page chat, and Close.
- The transcript scrolls above the bottom composer. As in Chat UI, Coss owns input growth and its single scroll surface: the input starts at one row and grows up to three on narrow screens (two to six rows from 800px), then scrolls longer drafts. The footer stays in the layout, so its actual height reserves space beneath the transcript.
- The footer reserves space for the device's bottom safe area. It moves above the virtual keyboard using the same Coss keyboard inset as the web app, without a separate fixed-position editor or hardcoded footer height.
- Swipe-to-dismiss belongs to the drawer chrome. The transcript and editor keep their own touch scrolling.
- Motion follows the existing web drawer transition and respects reduced motion.
Props and ownership
| Prop | Required | Contract |
|---|---|---|
open | Yes | Controlled visibility. |
title | Yes | Visible header title and accessible dialog name. |
labels | Yes | Localized close and openFullPage action labels. |
onDismiss | Yes | Reports Close, Escape, backdrop, or swipe dismissal. The host keeps the conversation and draft so reopening resumes them. |
onOpenFullPage | Yes | Reports expansion. The web host opens /home with the current conversation or transfers the unsent draft through in-app state. |
children | Yes | Conversation and composer content. The drawer does not own chat state or invoke the agent runtime. |
The composer wrapper uses the same slots as the web host so the drawer can position its footer around the keyboard:
import { AgentMobileSheet } from "@calcom/ai/react/ui/agent-mobile-sheet";
<AgentMobileSheet
open={open}
title={title}
labels={{ close: "Close", openFullPage: "Open full-page chat" }}
onDismiss={() => setOpen(false)}
onOpenFullPage={openFullPage}
>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">{conversation}</div>
<div className="shrink-0" data-slot="agent-composer-slot">
<div className="px-3 pb-3" data-slot="agent-composer-footer">
{composer}
</div>
</div>
</div>
</AgentMobileSheet>For mobile, the web host passes autoFocus={false} to the composer so opening the drawer does not immediately summon the keyboard. The drawer places initial focus on its container; the composer receives focus when the person selects it. The desktop agent popup instead focuses its editable composer on open. Closing the drawer dismisses the view; removing a session is a separate product action. The launcher resumes the last active conversation.
Production call sites
| File | Role |
|---|---|
packages/ai/src/react/ui/agent-mobile-sheet.tsx | Shared drawer presentation, header, touch scrolling, safe-area and keyboard treatment. |
apps/web/app/(use-page-wrapper)/(main-nav)/agent/agent-floating-toolbar.tsx | Selects the mobile host, preserves the session, and handles dismiss/expand. |
apps/web/app/(use-page-wrapper)/(main-nav)/agent/agent-chat.tsx | Supplies the real transcript and mention composer with the footer slots above. |
apps/design-system/components/agent-mobile-sheet-example.tsx | Synthetic, local-only documentation controller. |
Example entry points include /home and /bookings/upcoming in the web app: below 800px, use the agent launcher when the organization has the agent enabled. Verify virtual-keyboard movement and swipe dismissal on a touch device; resizing a desktop browser exercises layout but does not open a mobile keyboard.
Runtime adapters
Adapters depend on the runtime and localization layer; UI modules do not. There are three adapter modules:
| Module | Exports | Maps |
|---|---|---|
@calcom/ai/react/adapters/agent-chat-composer | AgentChatComposer, AgentChatComposerRoot | AgentChat input, busy and approval state, submit, stop, and localized Send and Stop labels |
@calcom/ai/react/adapters/agent-approval-card | ApprovalCard, ApprovalGroupCard | Shared localized approval labels into presentation cards |
@calcom/ai/react/adapters/agent-tool-part-card | ToolPartCard, ToolActivityGroup, ToolRow | AI SDK-like tool parts and a renderer registry into activity rows and truthful group summaries |
@calcom/ai/react/adapters/agent-chat-announcer | AgentChatAnnouncer | One persistent polite region that announces turn transitions (Thinking, Approval needed, Response ready, stopped, failed) once; mount it before the first prompt and pass announce={false} to visible indicators |
Use AgentChatComposer when an existing AgentChat owns the state:
import { AgentChatComposer } from "@calcom/ai/react/adapters/agent-chat-composer";
<AgentChatComposer chat={chat} inputLabel="Message Cal" placeholder="Ask Calโฆ" />While chat is busy, this adapter keeps Stop visible because AgentChat.submit does not accept another prompt in that state. It also disables the composer while an approval is pending.
For a custom connected layout, compose AgentChatComposerRoot with the flat AgentComposerFrame, AgentComposerInput, and other presentation exports from @calcom/ai/react/ui/agent-composer. The adapter does not duplicate or namespace those UI parts.
Approval and tool adapters keep runtime data out of the presentation layer:
import { ApprovalCard } from "@calcom/ai/react/adapters/agent-approval-card";
import { ToolActivityGroup } from "@calcom/ai/react/adapters/agent-tool-part-card";
<ApprovalCard
onRespond={respondToApproval}
prompt={approvalPrompt}
toolName="create-event-type"
/>
<ToolActivityGroup parts={toolParts} renderers={toolRenderers} />Connect the new UI to a real agent
Use the existing useAgentChat runtime; do not copy the playground's fixture store or timers into the app. The following is a minimal connected transcript, not a new backend or a complete global-agent implementation. Mount its controller once per conversation, above the popup/page presentation choice. Calling the hook separately in each host does not share its composer draft.
The caller supplies an authorized chat endpoint, localized labels, tool renderers, and approval descriptions. This example renders streamed text as plain React text (safe by default); rich Markdown must use the existing chatMarkdownToSafeHTML sanitizer.
import { AgentChatComposer } from "@calcom/ai/react/adapters/agent-chat-composer";
import { ApprovalCard } from "@calcom/ai/react/adapters/agent-approval-card";
import type { ApprovalPrompt } from "@calcom/ai/react/adapters/agent-approval-card";
import { ToolPartCard } from "@calcom/ai/react/adapters/agent-tool-part-card";
import type { ToolRendererRegistry } from "@calcom/ai/react/adapters/agent-tool-part-card";
import { AgentConversation, AgentMessage } from "@calcom/ai/react/ui/agent-conversation";
import { AgentReasoning } from "@calcom/ai/react/ui/agent-reasoning";
import { AgentStatusIndicator } from "@calcom/ai/react/ui/agent-status-indicator";
import { useAgentChat } from "@calcom/ai/react/use-agent-chat";
import type { UIMessage } from "ai";
import { getToolName, isToolUIPart } from "ai";
function ConnectedAgent({
api, initialMessages, labels, renderers, describeApproval,
}: {
api: string;
initialMessages: UIMessage[];
labels: { input: string; latest: string; reasoning: string; thinking: string; error: string };
renderers: ToolRendererRegistry;
describeApproval: (toolName: string, input: unknown) => ApprovalPrompt;
}) {
const chat = useAgentChat({ api, initialMessages });
return (
<div className="flex h-full min-h-0 flex-col">
<AgentConversation scrollToLatestLabel={labels.latest}>
{chat.messages.map((message) => (
<AgentMessage key={message.id} role={message.role === "user" ? "user" : "assistant"}>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={index} className="whitespace-pre-wrap text-sm">{part.text}</p>;
}
if (part.type === "reasoning") {
return (
<AgentReasoning key={index} completedLabel={labels.reasoning}
streamingLabel={labels.thinking} isStreaming={part.state === "streaming"}>
{part.text}
</AgentReasoning>
);
}
if (!isToolUIPart(part)) return null;
const toolName = getToolName(part);
if (part.state === "approval-requested") {
return (
<ApprovalCard key={part.toolCallId}
toolName={toolName} prompt={describeApproval(toolName, part.input)}
onRespond={(approved) => {
void chat.addToolApprovalResponse({ id: part.approval.id, approved });
}} />
);
}
return (
<ToolPartCard key={part.toolCallId} renderers={renderers}
part={{ ...part, type: `tool-${toolName}` }} />
);
})}
</AgentMessage>
))}
{chat.isBusy && <AgentStatusIndicator label={labels.thinking} status="thinking" />}
{chat.error && <p role="alert">{labels.error}</p>}
</AgentConversation>
<AgentChatComposer chat={chat} inputLabel={labels.input} placeholder={labels.input} />
</div>
);
}- Streaming: the SDK updates message parts. Render their current text; CSS only animates status treatments.
- Approvals: send the SDK approval ID through
addToolApprovalResponse. The hook resumes the turn after pending approvals are answered. UI approval buttons do not enforce server permissions. PreferAgentChatTranscript, which keeps answered approvals mounted and transitions them into Workingโฆ throughAgentApprovalPendingso the card never vanishes into a blank gap. - Stop and submit: the composer adapter wires these to the runtime. Unlike the mock playground, it does not implement queued submissions.
- History and identity: load authorized history before mounting with
initialMessages. Keep one controller and draft per conversation ID. Multi-conversation persistence, deletion and route access belong to the product; a client UID is not authorization. - Editing: the product stops the current turn, truncates
chat.messagesbefore the selected user message, restores its text throughchat.setInput, and focuses the composer. Never truncate another chat's state. - Context, files and errors: the playground's attachment control and context labels are visual examples, not uploads or context capture. Define validated payloads, supported message-part renderers and recoverable error behavior in the product.
For endpoint, actor, permission and tool wiring, follow the repository's build-calcom-ai-agent skill. Its legacy surface example remains applicable to existing feature chats; use this section for the new global-agent UI.
Complete prototype
The complete prototype is the canonical AgentPanel demo and composes the panel-facing UI modules with local state. Use the scenario switcher to inspect a blank new chat, approval, queued follow-up, long scrollable transcript, kitchen-sink markdown (headings, ordered and bulleted lists, links, badges), and calendar error without repeating the same frame six times.
The panel starts closed so loading this page does not move focus or scroll to the demo. Choose a scenario, then use Agent or a session in the action bar to open its AgentPanel popover. The action bar also includes history. Selecting a history item adds its session action without replacing existing actions; selecting an already-open item activates its existing action. AI responses, tool progress, approvals, message edits, and session state are simulated; no model, persistence, or mutation runs.
Product availability
This component family is documented here and in the AI Agents playground: the app page hosts the action bar outside the main inset and keeps the panel closed until a launcher or session is opened, and the agent page is the dedicated full-page chat. Routing Forms and other feature-specific chats continue using the legacy @calcom/ai/react/* implementations; migrating them is outside this page's scope.
Playground behavior
| Interaction | Expected behavior |
|---|---|
| Submit a new popup chat | Create a unique conversation and highlighted toolbar session; keep the popup anchored to Agent. |
| Submit a new full-page chat | Create its unique route and advance the simulated reply to completion. |
| Switch between page and popup | Each host selects its own chat ID. When both show the same ID, they share messages, approval state and draft. |
| Close a toolbar session | Remove only the toolbar button. The conversation remains in history and can be reopened. |
| Select history | Open the selected ID without replacing other toolbar sessions. New conversations join the same history as fixtures. |
| Expand / move to toolbar | Reuse the conversation, including edits and pending drafts. Moving to the toolbar navigates back, or to a new chat when there is no in-app back entry. |
| Copy / delete | Copy the current transcript as Markdown. Confirming delete removes that conversation from both hosts and history; deleting the page chat returns to a new chat. |
| Refresh the browser | Reset the mock session. Routes for newly created mock chats are not durable share links. |
Layout and controls
- The title history menu controls the page chat; toolbar history controls the popup.
- History uses the supported boolean
autoHighlight: typing highlights the first match; opening without a query leaves highlighting to the arrow keys or pointer. - An existing page chat offers New chat first in its history menu, an ellipsis menu, and a desktop Move to the toolbar button.
- Transcript and composer content are width-constrained; the scrolling parent is not, keeping the scrollbar at the page edge.
- A title uses its natural width in the header. It truncates only when it would exceed the remaining space before the overflow actions, so it never widens the page.
- An empty full-page chat centers the taller composer. It has no page-context control. The app-page Agent launcher attaches page context to a fresh popup chat.
- The queued fixture intentionally pauses to expose its controls; Send now resumes simulated progress.
What to reuse
Copy the presentation composition and the runtime integration contractโnot agent-demo-conversations.ts, mock messages, navigation counters, or simulated responses. The docs scenario switcher gets an isolated store; playground routes share an in-memory store for client-side navigation. Neither is production persistence.
The web app uses the floating action bar and popover at desktop widths and the mobile bottom drawer below md. The dedicated full-page chat remains available at every width. The playground's action bar currently demonstrates the desktop host; the focused bottom-drawer example above is available at all widths.
Sources
packages/ai/src/react/uiโ presentation componentspackages/ai/src/react/adaptersโ runtime and localization mappingapps/design-system/components/agent-*-example.tsxโ documentation-only state and compositionsapps/design-system/components/agent-result-list-example.tsxโ result-list domain prototypes
On This Page