@k2b/ui
Chat
Controlled chat timeline, composer, messages, activities, attachments, and model selection.
Open the task count to review a partial result. The place chip keeps its link, and the running icon stays solid.
TSX
Copy<div class="flex flex-col gap-1"> <Chat.Activity icon="ti ti-book" label={<>Read <ReferenceChip kind="object" title="Week 42" href="#chat-step-rows" /></>} /> <Chat.Activity icon="ti ti-alert-triangle" tone="warning" defaultOpen label={<><Chat.Activity.Toggle>3 of 4 tasks</Chat.Activity.Toggle> created in <ReferenceChip kind="place" title="Team Ops" href="#chat-step-rows" /></>} > <Chat.Preview> <Chat.Preview.Lines label="Tasks"> <Chat.Preview.Line icon="ti ti-check" meta="Created">Check inventory</Chat.Preview.Line> <Chat.Preview.Line icon="ti ti-check" meta="Created">Plan deliveries</Chat.Preview.Line> <Chat.Preview.Line icon="ti ti-check" meta="Created">Prepare the workshop</Chat.Preview.Line> <Chat.Preview.Line icon="ti ti-alert-triangle" tone="warning">Schedule pickup could not be created. Try again.</Chat.Preview.Line> </Chat.Preview.Lines> </Chat.Preview> </Chat.Activity> <Chat.Activity busy icon="ti ti-loader-2 k2b-spin" label="Creating 4 tasks in Team Ops …" /></div>Review the actual draft, then answer. Sending collapses the question immediately; completion keeps the next paragraph still.
Send Workshop delivery to Lea Berg?
You asked to check mail before sending.
- From
- Robin Examplerobin@example.com
- To
- Lea Berglea@example.com
- Cc
- Tom Webertom@example.com
- Bcc
- archive@example.com
Sends immediately after your answer.
Workshop delivery
Hello Lea, The delivery is ready for Friday. The report includes the revised quantities. Please check the attached list.
Delivery report.pdf
The draft includes outside content.
Read full draft- new: Lea receives the revised report.
- removed: The previous attachment is removed.
The conversation continues here.
TSX
Copy<Chat.Decision state={state()} sentence={<>Send <ReferenceChip kind="object" title="Workshop delivery" href="#draft" /> to <ReferenceChip kind="person" name="Lea Berg" />?</>} reason={{ text: "You asked to check mail before sending." }} preview={<Chat.Preview> <Chat.Preview.Header rows={[ { label: "To", people: [{ name: "Lea Berg", address: "lea@example.com" }] }, { label: "Bcc", emphasis: true, people: [{ address: "archive@example.com" }] }, ]} note="Sends immediately after your answer." /> <Chat.Preview.Title meta="Draft · version 3">Workshop delivery</Chat.Preview.Title> <Chat.Preview.Excerpt>Hello Lea, the report is ready for Friday.</Chat.Preview.Excerpt> <Chat.Preview.Line icon="ti ti-file-type-pdf" meta="PDF · 240 KB">Delivery report.pdf</Chat.Preview.Line> <Chat.Preview.ReadMore href="#draft">Read full draft</Chat.Preview.ReadMore> </Chat.Preview>} confirm={{ label: "Send to Lea", icon: "ti ti-send", onSelect: send }} decline={{ label: "Keep draft", onSelect: () => { setCurrent("declined"); setState("decided"); } }} outcome={<Chat.Outcome current={current()} lines={{ running: { tone: "running", text: "Sending the mail to Lea." }, done: { tone: "done", text: "Sent the mail to Lea.", time: "09:41" }, declined: { tone: "off", text: "Kept the draft.", detail: "You declined." }, }} />}/>Deletion, unavailable answers, a host-owned remember policy, and a routine with visible changes. Each question has its own answer.
Delete the old report?
This permanently deletes the file.
Send the draft to Lea?
Lea no longer has access to the attachment.
Ask before sharing the report?
Choose the policy for this chat.
Change the weekly report routine?
Check the new schedule and permissions.
Weekly report
Every removed: Monday new: Friday, send the report to Lea Berg.
Lea Berg and Tom Weber
- new: May send the weekly report.
- Cannot delete files.
TSX
Copy<Chat.Decision state={deleted() ? "decided" : "open"} sentence="Delete the old report?" reason={{ text: "This permanently deletes the file.", tone: "danger", icon: "ti ti-trash" }} confirm={{ label: "Delete report", variant: "danger", onSelect: () => { setDeleted(true); } }} decline={{ label: "Keep report", onSelect: keepReport }} outcome={<Chat.Outcome current="done" lines={{ done: { tone: "done", text: "Deleted the old report." } }} />}/><Chat.Preview.Sentence>Every <Chat.Mark change="added">Friday</Chat.Mark>, send the report.</Chat.Preview.Sentence><Chat.Preview.People people={[{ name: "Lea Berg" }, { name: "Tom Weber" }]}>Lea Berg and Tom Weber</Chat.Preview.People>History and recovery: done, declined, expired, answer lost, error, waiting, and a failed check. Each outcome reserves only the lines its own flow can reach.
TSX
Copy<Chat.Outcome current="done" lines={{ done: { tone: "done", text: "Sent the report.", time: "09:41" } }} /><Chat.Outcome current="declined" lines={{ declined: { tone: "off", text: "Kept the draft.", detail: "You declined." } }} /><Chat.Outcome current="lost" lines={{ lost: { tone: "warning", text: "Your answer could not be confirmed.", detail: "Check the sent folder before trying again." } }} /><Chat.Outcome current="error" lines={{ error: { tone: "error", text: "The mail could not be sent.", detail: "The mail service is unavailable." } }} />Objects, places, people, times, a gone object, and a long title cut in the middle. The full title remains the link's accessible name.
Delivery report.pdfWorkshopLea BergOld draftQuarterly delivery report for the entire workshop team October 2026.pdf
TSX
Copy<ReferenceChip kind="object" title="Delivery report.pdf" icon="ti ti-file-type-pdf" href="#report" /><ReferenceChip kind="place" title="Workshop" icon="ti ti-folder" href="#workshop" /><ReferenceChip kind="person" name="Lea Berg" href="#lea" /><ReferenceChip kind="time" label="Friday, 09:00" dateTime="2026-10-16T09:00:00Z" /><ReferenceChip kind="object" title="Old draft" gone /><ReferenceChip kind="object" title="Quarterly delivery report for the entire workshop team October 2026.pdf" href="#report" />Portable, controlled chat presentation with generic tool activity. The host owns protocol, persistence, uploads, and model execution.
TSX
Copy<Chat> <Chat.Timeline items={items()} conversationKey="fibel-demo" /> <Chat.Composer value={draft()} onValueChange={setDraft} mentions={mentions()} onMentionsChange={setMentions} accessory={<Chat.Tasks items={tasks} open={tasksOpen()} onOpenChange={setTasksOpen} />} placeholder="Write a message…" onSubmit={sendMessage} models={models} selectedModelId={model()} onModelChange={setModel} fileSelection={{ onSelect: addFiles }} menuActions={menuActions} commands={commands} commandSections={sections} commandScopes={scopes} searchCommands={async (query, signal, { scope }) => findCommands(query, signal, scope)} contextUsage={{ usage: usage(), contextWindow: 128_000 }} /></Chat>The conversation variant: one row with attach, a pill field that grows from one line to a third of its size container, and Send. "Aa" opens Bold, Italic and List in a row above the field, emoji and microphone sit inside it, and a hint line never moves it. Type :tada: or :dau for emoji. Tap the microphone to dictate, hold it for a voice message.
TSX
Copy<Chat.Composer variant="conversation" value={draft()} onValueChange={setDraft} onSubmit={({ text }) => send(text)} sendKey={settings.sendKey} formatting emoji={{ onOpen: setEmojiTarget, onPick: remember }} microphone={{ onDictate: dictation.toggle, onVoiceMessage: recordVoiceMessage, dictation: dictation.state(), onRestoreOriginal: dictation.restore }} hint={hint()} placeholder="Message Workshop"/><EmojiPicker.Popover anchor={emojiTarget()?.anchor} recent={recent()} onPick={(emoji) => { emojiTarget()?.insert(emoji); remember(emoji); }} onClose={() => setEmojiTarget(undefined)}/>Chat is a controlled compound component for assistants and other conversational workflows. It has no knowledge of Cloud sessions, providers, tool protocols, persistence, or uploads.
ChatTimeline softly fades its bottom edge while more messages remain below.
The latest-message button and composer stay outside the fade. At the end of
the conversation the fade disappears. Set scrollFade={false} to opt out;
forced-color mode always leaves content unmasked.
Use Chat
Compose Chat.Timeline and Chat.Composer inside Chat. The application owns messages, draft, attachments, model selection, run state, and mutations. The components own keyboard behavior, scrolling, focus, disclosures, structured actions, and accessible status presentation.
Timeline items are plain message or activity records. Message actions and attachments are structured arrays; activity bodies remain JSX so an application can render rich tool results without coupling the package to a protocol.
Chat.Timeline renders rows by id, which must be unique within the timeline.
A new record with a known id updates that row in place: the message element,
its content, and any state inside it stay, even when the record object, its
status, or its actions change. Give a message the same id from the moment it
streams until it is stored, so finishing a response does not remount it. Pass
the same content element when the content itself has not changed; a new
element replaces the old one.
Use leading when an activity needs a host-owned visual such as a favicon. Use
the optional accent only for source or application identity; success, warning, and
danger tones continue to own semantic state colors. Keep the rich activity
body in the host so Chat remains independent from tool protocols.
Activity labels use the normal font weight, and their description has the same
size; hierarchy comes from color and indent. Use ariaLabel only for the
whole-row summary form when its visible text abbreviates, for example "Worked
3 minutes, 49 steps" for "Worked 3 min · 49 steps".
Set busy on an activity while work is running. It applies a quiet horizontal
text-color-to-transparency shimmer to the title and static icon. Without a
semantic tone or accent, the busy icon uses --k2b-ai-accent, also with
reduced motion. Spinning icons stay solid so they remain visible. Reduced-motion
clients keep the text static. Streaming messages retain the minimal three-dot
indicator. Applications should not add another generating label.
Activity bodies are inset beneath their row by default. Set bodyInset={false}
when the body is a peer list that should align with the activity row itself.
On a chat timeline narrower than 48 rem or on a phone, labels wrap when the
row has no description. Rows with descriptions keep one line and truncate the
muted metadata first. Wide surfaces keep one line with an ellipsis. Set wrap
when the whole label and description must remain visible at every width; the
icon stays on the first line.
Put one Chat.Activity.Toggle in the label when only a few words, such as
"3 of 4 tasks", should open the body. The toggle is a button with its chevron
right after the words. The rest of the row stays ordinary content, and chips
keep their links. Pass children or renderBody for the body. Without a body,
the toggle renders plain text. Use one toggle per row.
Without a toggle, an expandable row uses <details> and <summary> and opens
on a press anywhere in it, also while its label or
trailing text re-renders, such as a ticking clock: presses land on the row, and
only links, buttons, and other controls inside it stay their own targets. With
a controlled open, the body renders as soon as the row opens, before the host
passes its new state back.
Import
import { Chat, type ChatTimelineItem } from "@k2b/ui";Controlled behavior
Chat.Composer calls onSubmit with an intent, untrimmed text, generic attachments, and optional inline mentions. The intent is send while idle and steer while a response is running. Return false or throw to restore the consumed draft, attachments, and mentions; failures are passed to onError.
Use state="running" to show Stop when the draft is empty. Once the user types, Send replaces Stop and submits a steer. menuActions populate the Plus menu unless addOpensCommands opens the command list; contextActions sit beside context usage. contextPopupAction places one small action inside the context details popup and keeps it available before token usage is reported. Model options can provide an icon or provider image. Use submitTools for compact application controls immediately before Send or Stop; footerTools remains beside the add/model controls. modelDetails places compact application-owned details immediately after the model selector. The application owns these controls and their state. On touch screens the footer keeps 0.625rem from the message field, so the touch areas of its compact controls never take taps from the field. footerContent replaces the footer during an application-owned interaction, such as audio recording, while preserving the editor. Pass undefined to restore the standard controls.
Every structured chat action declares exactly one behavior: onSelect for an
application callback or copyText for clipboard content. The same contract is
used by message, menu, and context actions. Set pressed for persistent toggle
state such as a saved helpful rating; inline actions expose it through
aria-pressed.
Pasted files use the same fileSelection.onSelect callback as the file picker
and drag-and-drop. Use the generic onPaste seam only for non-file content;
call preventDefault() synchronously only when the application replaces the
native paste. Attachments may expose one compact application-owned action such
as moving a text attachment back into the message field. Composer attachments
stay on one horizontally scrollable row, and the controlled text field grows
up to approximately fifteen visible lines. The conversation variant grows
differently; see Conversations between people.
Chat.Timeline follows new messages and growing rich content while the reader remains near the bottom. Content and viewport resizing keep the latest item visible without overriding a reader who has scrolled upward. A reader who opens or closes an expandable row in the timeline keeps that row where it was: the timeline does not follow the end for that change, and it keeps following new content only if the end is still in view afterwards. Set hasMore and onLoadOlder to load history while preserving the visible scroll position.
Use scrollToAnchorRef to receive a function that scrolls to a rendered anchorId and pauses following. It returns false if the anchor is not rendered yet; load the relevant history first. This also works behind an open dialog where browser focus is blocked. Following resumes when the reader returns to the bottom.
On hover-capable fine pointers, the timeline keeps its scrollbar thumb hidden until the timeline is hovered or contains keyboard focus. Its stable scrollbar gutter prevents the conversation from shifting when the thumb appears. Touch, coarse-pointer, and forced-color environments retain their normal visible scrollbar treatment.
Pass timeLabel for visible localized timestamps and createdAt for the
machine-readable dateTime value. createdAt alone intentionally renders no
runtime-locale text, which keeps the server HTML and the browser render identical.
Questions and outcomes
Use Chat.Decision for one question in a conversation. The host owns the answer,
execution, expiry, and any policy change. Use verb-and-target answers such as
"Send to Lea" and "Keep draft"; never offer "Approve all". Answers are at
least 44 px high; on phones they share the row, and a long label wraps
inside its button.
The sentence, preview title, header rows, and answers share one left edge, with
no separate column or card surface. Reason, preview line, outcome, and activity
text start 24 px from that edge, after their icon. The sentence uses
--k2b-chat-decision-size: 18 px, or 17 px when the chat timeline is narrower
than 48 rem, at weight 400. Outside a timeline, the window sets this breakpoint.
On narrow surfaces the answers grow to share the row.
Use ReferenceChip for objects, places,
people, and times inside the sentence. Chips show at most 40 user-perceived
characters, cut in the middle, while retaining the full title as their accessible
name and tooltip. Person chips show a name and avatar, never an address. Put
addresses in Chat.Preview.Header. A gone object or place is struck through and
has no link.
Set state synchronously in each answer's onSelect, before awaiting work, so
the question collapses immediately. open shows the question;
sending, decided, and expired show the outcome slot. An answer given here focuses Chat.Outcome and
announces its current sentence. An outcome action that reopens the question
returns focus to the sentence and briefly highlights it. A host that cannot
take an answer returns the question from sending to open; focus then goes
back to the sentence as well. Changes from elsewhere do not move focus. To
lead the person to an open question from elsewhere, such as a list of waiting
questions, scroll to its anchorId and focus .k2b-chat-decision__sentence
(tabindex="-1"): any focus that does not come from a press on the sentence
itself briefly highlights it the same way. Handle rejected answer callbacks
with onAnswerError and show a recoverable outcome.
Pass Chat.Outcome only the lines this flow can reach. Every supplied line
occupies the same grid cell, including hidden alternatives, so the longest line
reserves the height. This keeps running-to-done transitions still without
reserving space for unrelated outcomes. A part that arrives later, such as the
time a call finished, needs its room from the start too: give the hidden line a
stand-in of the same width, such as the widest time of the day, until the real
value is known, so the line cannot wrap when it arrives. Only the current line is visible and
accessible; inactive action links leave the tab order. actions under a line
stay in the cell while the line is hidden, so a line with buttons reserves
their row from the start. When a line's own action switches current, such
as "Send again" to running, focus stays on the outcome instead of the hidden
link. running sets aria-busy on the words only, never
on an ancestor of the live region. Pass onActionError to receive an error a
line's action throws or rejects with, and show a recoverable line. While an
action's promise is pending, further presses are ignored. A polite
live region starts empty in SSR and on initial history
mount; it announces an answer given here and every subsequent current change,
including detail, time, and action link text with spaces between them; buttons
under the line are not announced.
Use unavailable to disable confirm and remember while keeping decline enabled.
Its reason replaces the original reason in the same described element. The
optional quiet remember answer is host policy: the component neither stores
consent nor widens permissions. On touch screens it is a full 44 px row, so a
low tap on either answer never reaches it.
Compose the actual content with Chat.Preview: header fields stand immediately
before their values; emphasis identifies a Bcc label; note can say when a mail
will be sent. Excerpt preserves line breaks and fades after about three lines.
ReadMore should open the exact version being reviewed. When there is no such
page, ShowAll shows the rest of an excerpt in place: give the Excerpt an
id, pass it as controls, set expanded on show, and remove the button. The
focus then moves to the text. Decide whether to offer it from the text itself,
not from a measurement, so the server and the first browser frame agree, and
set expanded on every text without a page that you do not offer it for, so an
estimate that is too short never clips anything.
Lines names a list of
consequences, and Line can also stand alone for an attachment or warning.
People shows overlapping decorative avatars followed by readable names.
A preview inside an activity body uses 13 px, including metadata and warnings.
Elsewhere it keeps 14.5 px.
Use Chat.Mark change="added" for new words and change="removed" for removed
words, or mark an entire preview line with its mark prop. Screen readers hear
"new:" / "removed:" in English and "neu:" / "weg:" in German. Added marks use
--k2b-mark-accent, which defaults to --k2b-ai-accent; a host may override it
for the local context. Use Chat.Activity tone="warning" for a partial result
or an answer whose outcome is not yet known. An activity label may be a
sentence with ReferenceChips, such as "Read [Week 42]". With
Chat.Activity.Toggle, chips in an expandable row keep their href. In the
whole-row summary form, give chips no href to avoid nested controls.
import { Chat, ReferenceChip, type ChatDecisionState } from "@k2b/ui";
import { createSignal } from "solid-js";
export function MailQuestion() {
const [state, setState] = createSignal<ChatDecisionState>("open");
const [current, setCurrent] = createSignal("running");
return <Chat.Decision
state={state()}
sentence={<>Send the draft to <ReferenceChip kind="person" name="Lea Berg" />?</>}
reason={{ text: "You asked to check mail before sending." }}
preview={<Chat.Preview><Chat.Preview.Title>Workshop delivery</Chat.Preview.Title></Chat.Preview>}
confirm={{ label: "Send to Lea", onSelect: () => {
setCurrent("running");
setState("sending");
// Complete the host-owned operation, then set current and state.
} }}
decline={{ label: "Keep draft", onSelect: () => {
setCurrent("declined");
setState("decided");
} }}
outcome={<Chat.Outcome current={current()} lines={{
running: { tone: "running", text: "Sending the mail to Lea." },
done: { tone: "done", text: "Sent the mail to Lea.", time: "09:41" },
declined: { tone: "off", text: "Kept the draft.", detail: "You declined." },
}} />}
/>;
}API reference
type ChatRole = "user" | "assistant" | "system" | "tool";
type ChatMessageStatus = "pending" | "streaming" | "complete" | "error";
type ChatActivityTone = "neutral" | "ai" | "success" | "warning" | "danger";
type ChatActionBase = {
id: string; label: string; icon?: string; variant?: "danger"; disabled?: boolean; pressed?: boolean;
pressedTone?: "success" | "danger";
};
type ChatAction = ChatActionBase &
({ onSelect: () => void | Promise<void>; copyText?: never } | { copyText: string; onSelect?: never });
type ChatAttachment = {
id: string; name: string; size?: number; kind?: "file" | "image" | "resource"; icon?: string;
previewUrl?: string; alt?: string; href?: string; data?: unknown; action?: ChatAction;
};
type ChatModelOption = {
id: string; label: string; description?: string; image?: string; icon?: string;
capabilities?: readonly string[];
};
type ChatComposerState = "idle" | "submitting" | "running" | "stopping";
type ChatSubmitIntent = "send" | "steer" | "queue";
type ChatSubmitInput = {
intent: ChatSubmitIntent; text: string; attachments: readonly ChatAttachment[];
mentions?: readonly ChatMention[];
};
type ChatMention = { start: number; end: number; attachment: ChatAttachment };
Decision, outcome, preview, and marks
Chat.Decision, Chat.Outcome, Chat.Preview, and Chat.Mark are members of
Chat; import their types directly from @k2b/ui.
type ChatDecisionState = "open" | "sending" | "decided" | "expired";
type ChatDecisionAnswer = {
label: string; icon?: string; onSelect: () => void | Promise<void>;
};
type ChatDecisionReason = {
text: JSX.Element; icon?: string; tone?: "neutral" | "danger" | "warning";
};
type ChatDecisionProps = {
state: ChatDecisionState; sentence: JSX.Element; reason: ChatDecisionReason;
preview?: JSX.Element;
confirm: ChatDecisionAnswer & { variant?: "ai" | "primary" | "danger" };
decline: ChatDecisionAnswer; remember?: ChatDecisionAnswer;
unavailable?: ChatDecisionReason; outcome?: JSX.Element;
onAnswerError?: (error: unknown) => void; anchorId?: string | number; class?: string;
};
type ChatOutcomeTone = "running" | "done" | "off" | "waiting" | "warning" | "error";
type ChatOutcomeLine = {
tone: ChatOutcomeTone; text: JSX.Element; detail?: JSX.Element; time?: string;
icon?: string; action?: { label: string; onSelect: () => void | Promise<void> };
actions?: JSX.Element;
};
type ChatOutcomeProps = {
lines: Readonly<Record<string, ChatOutcomeLine>>; current: string;
onActionError?: (error: unknown) => void; anchorId?: string | number; class?: string;
};
type ChatPreviewPerson = { name?: string; address?: string };
type ChatPreviewHeaderRow = {
label: string; people?: readonly ChatPreviewPerson[]; value?: JSX.Element; emphasis?: boolean;
};
type ChatPreviewProps = { children: JSX.Element; class?: string };
type ChatPreviewHeaderProps = { rows: readonly ChatPreviewHeaderRow[]; note?: JSX.Element };
type ChatPreviewTitleProps = { children: JSX.Element; meta?: JSX.Element };
type ChatPreviewReadMoreProps = { href: string; children: JSX.Element; title?: string };
type ChatPreviewLineProps = {
icon: string; accent?: string; tone?: "default" | "muted" | "warning";
mark?: "added" | "removed"; meta?: JSX.Element; children: JSX.Element;
};
type ChatPreviewPeopleProps = {
people: readonly { name: string; src?: string | null }[]; children: JSX.Element;
};
type ChatMarkProps = { change: "added" | "removed"; children: JSX.Element };| Member | Props / behavior |
|---|---|
Chat.Decision |
ChatDecisionProps; confirm defaults to ai, decline to subtle, remember to text. Reason defaults to neutral and an info icon. |
Chat.Outcome |
ChatOutcomeProps; tone chooses the default icon, icon overrides it. The root accepts programmatic focus with tabindex=-1. |
Chat.Preview |
ChatPreviewProps; groups preview parts without a surface. |
Chat.Preview.Header |
ChatPreviewHeaderProps; semantic description list with optional note. |
Chat.Preview.Title |
ChatPreviewTitleProps; title with quiet trailing metadata. |
Chat.Preview.Sentence |
{ children: JSX.Element }; a rule or instruction. |
Chat.Preview.Excerpt |
ChatPreviewExcerptProps; fading text excerpt; expanded shows it whole and makes it focusable. |
Chat.Preview.ReadMore |
ChatPreviewReadMoreProps; ordinary link to the reviewed version. |
Chat.Preview.ShowAll |
ChatPreviewShowAllProps; quiet button that shows an excerpt whole in place and focuses it. |
Chat.Preview.Lines |
{ label?: string; children: JSX.Element }; ul with an optional accessible name. |
Chat.Preview.Line |
ChatPreviewLineProps; li inside Lines, otherwise p; accent colors its decorative icon. |
Chat.Preview.People |
ChatPreviewPeopleProps; decorative avatars and host-supplied names. |
Chat.Mark |
ChatMarkProps; semantic mark or del with a localized spoken prefix. |
Commands and file selection
type ChatCommandContext = {
setValue: (value: string) => void; submit: () => void; focus: () => void;
openModelSelection: () => Promise<string | null>;
};
type ChatCommandResult = void | "cancelled" | { done: string } | { mention: ChatAttachment };
type ChatCommandDraft = { value: string; mentions: readonly ChatMention[] };
type ChatCommandOpenResult = void | "cancelled" | { failed: string };
type ChatCommand = {
id?: string; name: string; description: string; icon?: string; label?: string;
keywords?: readonly string[]; reason?: string; busyLabel?: string; disabled?: boolean;
section?: string; scope?: string; hiddenUntilSearch?: boolean; app?: string; recent?: boolean;
action?: (context: ChatCommandContext) => ChatCommandResult | Promise<ChatCommandResult>;
mention?: ChatAttachment;
open?: (draft: ChatCommandDraft, signal: AbortSignal, follow: () => void) => Promise<ChatCommandOpenResult>;
selected?: boolean; searchAgain?: boolean; persistent?: boolean;
};
type ChatCommandSection = {
id: string; label: string; searched?: boolean; searchOnly?: boolean; overviewOnly?: boolean;
fixedWhileOpen?: boolean; pending?: boolean;
};
type ChatCommandScope = { id: string; title: string; words: readonly string[] };
type ChatCommandSearch = { scope?: string; opening: number };
type ChatFileSelection = {
onSelect: (files: readonly File[]) => void | Promise<void>; onError?: (error: unknown) => void;
choose?: () => Promise<readonly File[]>; accept?: string; multiple?: boolean; disabled?: boolean; label?: string;
};
type ChatPasteHandler = (event: ClipboardEvent & { currentTarget: HTMLTextAreaElement; target: Element }) => void;
Use open(draft, signal, follow) for a row that saves before opening another application.
draft contains the value without /… and the adjusted mentions. During the
promise, editing waits, the token and list stay in place, and the row shows
Opening … with a spinner (or busyLabel). A void result removes the token
in one undo step. "cancelled" changes nothing. { failed } keeps the list and
draft unchanged and replaces the row description; choosing it again retries.
A rejection keeps the token, closes the list, and shows the error message in
the status line. Escape, a press outside, or moving the caret away closes the
list and cancels the opening: signal aborts, editing returns at once, and
the token stays; a later result changes nothing. A new draftKey cancels like
Escape unless the opener called follow() right before changing it itself,
such as to a chat it creates to save into. Then the next change moves the busy
line to the status line, and Escape still cancels. Any further unannounced
change cancels too. signal also aborts when the opening ends in { failed }
or a rejection, so the opener can save the draft with its token again; it stays
quiet only on success.
Set ChatCommandSection.pending while local rows are loading. Put this section
last. Its heading spins and its end holds three placeholders in the overview,
one while searching; Nothing found waits for it. When the rows arrive in the
overview, the list keeps the height it had with the placeholders until the next
keystroke and scrolls what does not fit, so no visible row moves. persistent keeps a row in every search of
its section, after matching rows, so an explicit retry remains reachable.
searchAgain: true starts a new opening and runs searchCommands again.
Give commandSections in display order and each command a section id. The
order stays fixed while searching. The overview keeps consumer row order;
searching ranks rows inside each section. Commands without a known section
appear at the end without a heading. Without commandSections, the list stays
flat. Each id (defaulting to name) appears once among visible matches: the
first section wins, then a matching static row wins over a server row in the
same section. A static row without a match never hides a fresh server result.
Search ignores case, accents and German umlauts (ä=a, ß=ss). Its ranking
ladder is: title starts with query, title word starts with query, title contains
query, keyword starts with query, app starts with query, description word
starts with query (3+ characters), then one typo in a title word or keyword
prefix (4+ characters). The title is label ?? name; when a label exists,
name remains a search word alongside keywords. app is searchable and is
not displayed. Equal ranks keep static rows first, then server rows, each in
consumer order. Set recent: true on a row this person used recently: while
searching, a matching recent row moves one step up the ladder and comes before
rows of equal rank, so a recent row whose title contains the query comes
before one whose title word starts with it, but after a title start. The
overview keeps consumer order.
searchCommands(query, signal, { scope, opening }) runs after a 120 ms debounce. Fresh
server results remain visible even without a local match, after matching rows.
Retained results from an earlier query must match the new query; changing scope
clears them. Marking follows the top row until an arrow key moves it, then
stays on that entry while results arrive. Typing starts at the top again.
Enter and Tab wait for fresh server rows; clicks may choose retained matching
rows. Local matches remain available while loading.
Server rows are requested again when the composer’s running state changes, so
reasons that depend on it stay current.
The opening number identifies one opening of the list. It stays the same
while typing, narrowing, or moving with arrow keys. A new /, closing and
reopening the list, or switching drafts starts another opening. Consumers can
cache discovery per opening with their own cancellation controller; the search
signal still belongs to one query.
Set searchAgain: true for a retry row. Enter, Tab, and click preserve the
current /…, query, and scope, run no action, and immediately search with a new
opening. Current matching rows remain visible while loading.
Use matchesChatCommand(command, query) from @k2b/ui to filter server rows
with the same ranking rules. It returns true for an empty or whitespace query.
Fresh server rows are never removed by the composer, so consumers own that
filtering.
Use commandScopes for first-word narrowing. Only a word followed by
whitespace can resolve: an exact word wins if unique. Otherwise, with at least
three letters, the scope with the shortest extension wins; a tie resolves to
nothing. Both paths ignore case and accents. A resolved scope shows one group
with scope.title, containing only server rows. The search callback receives
the rest of the query and { scope: scope.id, opening }; otherwise it receives
the whole query and { scope: undefined, opening }.
Tab on a command with scope replaces the query after / with that scope's
words[0] and a space, through normal undoable editing. Enter runs its action or
inserts its mention; a scope-only row also narrows on Enter. An unavailable
row keeps its reason and never narrows.
An action may return { mention: attachment } after a picker resolves. The
reference replaces the original token at its position, using the same edit as
a row's mention. Ctrl/⌘+Z restores /… in one step. Returning "cancelled"
keeps the token. If the draft changed, or moved to another draftKey, while
the picker was open, the composer inserts nothing and shows a visible
explanation.
Set attachFiles: true to use the composer's fileSelection from a slash row.
Its choose function runs synchronously inside the key or click activation so
the device's file dialog can open. The token leaves; resolving with [] brings
it back if the draft is unchanged. The same onSelect handles selected files.
A row with reason cannot open file selection.
Use commandFallback({ query, scope }) for a final row built immediately from
the current search. Unscoped, it follows Nothing found for “…” when no rows
match and does not count as a match. In a scope, it is the last row inside that
group. Return undefined when no fallback applies. It participates in keyboard
selection; adding a space after an unmatched unscoped query still closes the
list. The overview has no fallback.
Set hiddenUntilSearch for rows that do not apply to the current context.
They are absent from the overview and appear with their reason when matched;
scoped results do not hide them. A searched section shows a quiet heading
spinner while loading. An empty section gets a heading and placeholder only
before the first search settles for this slash token and scope, or when retained
results still contain a row for that section. Other empty sections stay hidden
while the next query loads. A resolved scope always keeps its title and shows a
placeholder while loading without rows. Placeholders are skipped by arrow keys.
A searchOnly section, such as
files of the current chat, is absent from the overview, placeholder included,
and appears while searching or narrowed. An overviewOnly section, such as
recently used references, appears only in the overview; while searching or
narrowed, the same entries can appear in their own sections as recent rows.
Set fixedWhileOpen on a section whose local rows load in the background and
that sits above others, such as recently used references at the top. Its rows
are the ones it had when this / opened the list. A row that arrives while
the list is open waits for the next /, so it never pushes a visible row
down; a retry row's new opening does not count. Rows that go away still leave
at once. Unlike pending, the section reserves no place: when its rows are
not there yet, the list opens without it.
A settled empty list says Nothing found for “<query>”. A scope with no search
text uses the generic empty message. After an unmatched query settles, a
trailing space closes the list for that / and leaves ordinary text. Resolving
a scope keeps it open. / opens nothing, so 50 / 2 stays arithmetic.
A separate polite screen-reader status announces the number of suggestions
350 ms after the visible model settles, without repeating the same text for
the same query. Action status and unavailable reasons use their own region.
Each row shows a title and at most one description or reason beneath it. Both
wrap; nothing is truncated. The title match is bold. Rows have no preview,
badges, key hints, or /name suffix. Groups have labelled headings; arrow keys
wrap across all rows and skip placeholders. Emoji suggestions keep their own
markup and selection behavior.
Set selected: true to show a trailing check mark and localized visually
hidden “selected” text. This marks a chosen value independently of the row
currently marked by the arrow keys.
Command actions can call await context.openModelSelection() to open the
composer's own model control. It resolves with the chosen model id after
onModelChange runs, or null on Escape, outside close, or an unavailable
control (including running, no models, no change callback, or footerContent
replacing the footer). For the states your application knows, give the command
a reason, or drop its action so Enter narrows to its scope; it never
resolves null silently. The control stays
usable while that command awaits a choice. Return "cancelled" on null
to restore the token. The action's normal completion restores field focus.
Set reason when a command is unavailable. It replaces the description and is
associated with the option for screen readers. Enter, Tab, and click keep the
list open, perform no action, and announce the reason. Legacy disabled still
prevents selection and keeps the description.
An action returning "cancelled" restores the exact /… token and mentions
when the draft is unchanged. Rejections use the same restoration guard, and a
restored draft leaves no undo step behind. The caret returns after the token
and the list stays closed until the next edit.
The persistent status line shows busyLabel while working, { done } for
about three seconds, and a failure message until the next edit, the next
command, or a switch to another draft.
Command failures are reported once there, without calling onError; only a
failure that arrives after the composer unmounted goes to onError.
The visible status hides while suggestions are open and takes no layout space.
Only plain Enter or Tab chooses a command. Shift+Enter breaks the line,
Shift+Tab moves focus back, and Ctrl/⌘+Enter sends even with suggestions open.
Nothing chooses during IME composition. Escape closes suggestions for the
current /, including while more text is typed into that token; a new /
can open them again.
Root and timeline
type ChatRootProps = {
children: JSX.Element; class?: string; label?: string;
};
type ChatMessageItem = {
kind: "message"; id: string; role: ChatRole; content?: JSX.Element; label?: string;
createdAt?: string | Date; timeLabel?: string; status?: ChatMessageStatus;
attachments?: readonly ChatAttachment[]; actions?: readonly ChatAction[];
actionDisplay?: "auto" | "inline" | "menu"; anchorId?: string | number; class?: string;
};
type ChatActivityItem = {
kind: "activity"; id: string; label: JSX.Element; description?: string; icon?: string; leading?: JSX.Element;
accent?: string; tone?: ChatActivityTone; busy?: boolean; trailing?: JSX.Element; defaultOpen?: boolean;
anchorId?: string | number; content?: JSX.Element; class?: string;
};
type ChatTimelineItem = ChatMessageItem | ChatActivityItem;
type ChatTimelineProps = {
items: readonly ChatTimelineItem[]; conversationKey?: string | null; loading?: boolean; hasMore?: boolean;
loadingOlder?: boolean; onLoadOlder?: () => boolean | void | Promise<boolean | void>; emptyTitle?: string;
emptyDescription?: string; navigation?: JSX.Element; onActionError?: (error: unknown) => void;
viewportRef?: (element: HTMLDivElement) => void; contentRef?: (element: HTMLDivElement) => void;
scrollToAnchorRef?: (scrollToAnchor: (anchorId: string | number) => boolean) => void;
label?: string; followThreshold?: number; scrollFade?: boolean; class?: string;
};
Composer
type ChatComposerProps = {
value: string; onValueChange: (value: string) => void;
onSubmit: (input: ChatSubmitInput) => boolean | void | Promise<boolean | void>;
runningSubmitIntent?: "steer" | "queue"; onStop?: () => void | Promise<void>;
onError?: (error: unknown) => void; state?: ChatComposerState; attachments?: readonly ChatAttachment[];
onAttachmentsChange?: (attachments: readonly ChatAttachment[]) => void; fileSelection?: ChatFileSelection;
onPaste?: ChatPasteHandler; menuActions?: readonly ChatAction[]; addOpensCommands?: boolean; models?: readonly ChatModelOption[];
selectedModelId?: string | null; onModelChange?: (modelId: string) => void;
commands?: readonly ChatCommand[]; contextUsage?: ChatContextUsageData;
commandSections?: readonly ChatCommandSection[]; commandScopes?: readonly ChatCommandScope[];
searchCommands?: (query: string, signal: AbortSignal, search: ChatCommandSearch) => Promise<readonly ChatCommand[]>;
mentions?: readonly ChatMention[]; onMentionsChange?: (mentions: readonly ChatMention[]) => void;
accessory?: JSX.Element; draftKey?: unknown;
contextActions?: readonly ChatAction[]; contextPopupAction?: ChatAction; footerTools?: JSX.Element; modelDetails?: JSX.Element;
submitTools?: JSX.Element; footerContent?: JSX.Element; placeholder?: string; label?: string;
inputLabel?: string; disabled?: boolean; error?: string; focusToken?: unknown; class?: string;
variant?: "default" | "conversation"; sendKey?: "enter" | "mod-enter"; formatting?: boolean;
emoji?: ChatComposerEmoji; microphone?: ChatComposerMicrophone; hint?: JSX.Element;
};
type ChatDictationState = "listening" | "refining" | "refined" | "unrefined" | "interrupted";
type ChatComposerMicrophone = {
onDictate?: () => void; onVoiceMessage?: () => void; dictation?: ChatDictationState | null;
onRestoreOriginal?: () => void; disabled?: boolean;
};
type ChatComposerEmoji = {
onOpen: (context: { anchor: HTMLElement; insert: (text: string) => void }) => void;
skinTone?: EmojiSkinTone; onPick?: (emoji: string) => void;
};
Direct message and activity components
type ChatMessageProps = {
role: ChatRole; children?: JSX.Element; label?: string; createdAt?: string | Date; timeLabel?: string;
status?: ChatMessageStatus; attachments?: readonly ChatAttachment[]; actions?: readonly ChatAction[];
actionDisplay?: "auto" | "inline" | "menu"; anchorId?: string | number;
onActionError?: (error: unknown) => void; class?: string;
};
type ChatActivityProps = {
label: JSX.Element; ariaLabel?: string; description?: string; icon?: string; leading?: JSX.Element; accent?: string;
tone?: ChatActivityTone; busy?: boolean; wrap?: boolean; trailing?: JSX.Element; open?: boolean;
onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; bodyInset?: boolean;
anchorId?: string | number; children?: JSX.Element; renderBody?: () => JSX.Element; class?: string;
};
type ChatActivityToggleProps = { children: JSX.Element };Chat.Activity.Toggle takes ChatActivityToggleProps. Put it in an activity's
label to open only that activity's body.
ChatRootProps belongs to Chat, the other component props to .Timeline, .Composer, .Message, and .Activity. ChatActionBase is the common shape shown here; consumers import ChatAction. For usage data see Context usage.
Defaults: composer state="idle", runningSubmitIntent="steer"; timeline followThreshold=96 CSS pixels. Set runningSubmitIntent="queue" to submit queued messages while running. submitting and stopping block submission. onLoadOlder can return false to report that no page was loaded; rejected loads become retryable history errors. onStop may be async and failures reach onError.
Chat.Activity supports controlled open/onOpenChange; timeline activity records expose only defaultOpen. bodyInset belongs to the direct Activity component, not the timeline record. Message/activity content is JSX, not automatically parsed Markdown. Use MarkdownView for Markdown strings.
Accessibility
Pass a useful conversation label when more than one chat is visible. Visual role labels are intentionally omitted, while screen readers still receive the message role. Time, status, menus, attachments, model selection, context usage, and history loading remain keyboard reachable and named.
User metadata appears on hover or keyboard focus and remains visible on devices without hover. Color and icons are supplementary.
Runtime
Initial messages render on the server. Editing, commands, scrolling, file selection, menus, model changes, and submission require browser code.
Raw files selected, dropped, or pasted are handed to
fileSelection.onSelect. With fileSelection.choose, Attach files calls it
inside the menu activation instead of opening the device's file dialog, and
hands its files to onSelect, only the first one when multiple is false;
a rejection reaches onError. The package never uploads, persists, streams,
authorizes, retries, or executes tools.
The family uses --k2b-ai-accent, --k2b-ai-accent-hover, --k2b-ai-border, and --k2b-ai-surface, which can be themed independently from the general accent stack.
Example
import { Chat, MarkdownView, type ChatTimelineItem } from "@k2b/ui";
import { createSignal } from "solid-js";
export function Conversation() {
const [draft, setDraft] = createSignal("");
const [items, setItems] = createSignal<ChatTimelineItem[]>([
{ kind: "message", id: "welcome", role: "assistant", content: "How can I help?" },
]);
return (
<Chat label="Project conversation">
<Chat.Timeline items={items()} conversationKey="project" />
<Chat.Composer
value={draft()}
onValueChange={setDraft}
onSubmit={({ text }) => {
setItems((current) => [...current, {
kind: "message", id: crypto.randomUUID(), role: "user",
content: <MarkdownView markdown={text} />,
}]);
}}
/>
</Chat>
);
}The timeline shows the latest message before JavaScript loads. Messages remain in chronological order, and loading older messages preserves the reader's position.
Feedback actions can set pressedTone="success" or pressedTone="danger" to
mark their selected state with the semantic icon color and no persistent
background. pressed continues to expose the toggle state to assistive technology.
Activity labels without descriptions wrap on narrow chat surfaces and phones.
Rows with descriptions and wide surfaces keep one line with an ellipsis. Use
renderBody for expensive
diagnostic details: it is mounted only while the activity is open.
Use a bounded, keyboard-focusable scrolling region for long input/output payloads.
Busy titles and static icons use the same text shimmer as inline guidance, with
reduced-motion and forced-colors support. Spinning icons stay solid. Waiting for
user input is a separate state.
Working plans
Use Chat.Tasks above the composer for a compact working plan. Pass items
with stable id, content, and status (pending, in_progress, completed,
cancelled), controlled open/onOpenChange, a label, a progressLabel,
and localized statusLabels. The component renders one colored segment per
item and a bounded, scrollable disclosure. Status symbols and labels complement
color; cancelled work has a striped segment and a struck-through description.
The host decides when a plan exists, persists it, counts cancelled work separately, and translates execution state into labels. Do not label a paused current step as running. This component does not start tasks or infer progress.
Message file attachments use compact horizontal chips with an icon and a single
filename line. Long filenames truncate; the attachment row wraps when needed.
Image attachments retain their thumbnail presentation. When a thumbnail no
longer loads, for example because the file was deleted, the chip shows the
attachment icon instead of a broken image and keeps the filename as its title.
A changed previewUrl loads the thumbnail again.
Application-owned model details
Use Chat.ContextPopup in modelDetails for a compact button with custom
details, such as an application's usage indicator. Pass the trigger content as
children, content for the panel, and a complete aria-label that names the
state and its value. The button defaults to type="button" and to the same
trigger style as the context indicator; a class replaces that style. Hover
previews the panel; click or keyboard activation pins it open. Escape and
outside click close it. onOpen runs each time the panel opens, which is the
place to refresh its data instead of offering a refresh button. Interactive
panel content is reachable with Tab. Popup-owned click, pointer-enter/leave and
ref props are not part of its public props. Applications own data loading and
authorization.
Build the panel with Chat.ContextPanel so it matches the context details: it
takes a title and lays out its children with the same type and spacing. Inside
it use a dl with one div per dt/dd row, an xs ProgressBar, and a short
p for a muted note. Wrap each repeated group in a section.
<Chat.ContextPopup
aria-label="Usage: 35%"
onOpen={refresh}
content={
<Chat.ContextPanel title="Usage">
<section>
<dl>
<div>
<dt>Used</dt>
<dd>35%</dd>
</div>
</dl>
<ProgressBar value={35} size="xs" label="Usage" />
<p>Resets in 5 hours</p>
</section>
</Chat.ContextPanel>
}
>
<ProgressRing value={35} />
</Chat.ContextPopup>An icon-only trigger, such as <i class="ti ti-infinity" />, takes the same
box as a ProgressRing, so one can replace the other without moving the
composer. An empty ring means 0 %, so do not use one for a value you do not
have: show <i class="ti ti-circle-dashed" /> when the value could not be
loaded. While the state is not known yet, render
<span class="k2b-chat-context" aria-hidden="true"> with the same dashed
circle: it keeps the trigger's box and is not interactive.
Composer commands and mentions
Use commands for local actions or reference choices, and searchCommands(query, signal, { scope }) for asynchronous discovery. Commands match at the caret, including in
the middle of a draft. A long command name or description wraps in the
suggestion list instead of ending in an ellipsis. A reference choice supplies mention: ChatAttachment;
control its ranges with mentions and onMentionsChange. The submitted text is
untrimmed so UTF-16 range offsets remain valid. A modified reference becomes
plain text; undo/redo restores both text and reference identity.
Set addOpensCommands to let + open the same list as /, with a search
field on top. This works in both composer variants when commands or
searchCommands is provided. The search field takes focus and owns the
combobox attributes; the textarea keeps its text. Typing filters the same rows,
and Tab on a scope row writes its narrowing word into the search field.
Ctrl/⌘+Enter closes the list and sends, as it does from the textarea.
The panel keeps its opening height while results change.
A reference inserts at the textarea caret stored when + was pressed. If text was selected, it inserts at the selection's end and preserves that text. A space separates the name from a preceding non-whitespace character, and a space follows the name. Reference ranges cover only the name; undo restores text and reference identity in one step. If the draft becomes shorter while the list is open, insertion stays within its current length.
Escape, + again, Back on a phone, or an outside press closes the list and
restores the textarea's focus and selection. An outside press with any pointer
type acts only to close the list; it does not activate anything underneath.
Pressing the textarea closes the list and lets that press place the caret.
Moving focus elsewhere, such as with Shift+Tab, closes without taking focus
back. Changing draftKey closes the list too.
With this option, menuActions are not shown. Offer those actions as command
rows and file selection as a row with attachFiles: true. That row uses
fileSelection, including its choose callback. Closing a picker without a
choice restores the draft and the stored selection.
Pass the task list through accessory. An empty conditional accessory reserves no
row or spacing, including after mount. Suggestions use that same location and
restore the task list without changing its open state. Without an accessory, they
float above the composer, which does not move when they open or close. Set draftKey when
switching conversations so undo cannot bring content from another chat back.
On touch-only phones (narrow screens, or low screens in landscape), command
suggestions form a column-wide sheet directly above and flush with the composer.
Rows are at least 48px tall, with the name above the description. The sheet can
grow up to the scroll container or visual viewport, minus the top safe area.
A tap beside it closes only the list and reaches nothing beneath; the field
keeps focus and the keyboard stays open. Back closes the list first through one
same-URL history entry, removed on every other close path. A Back or Forward
that only moves onto or off this entry does not reach application popstate
listeners when the browser supports the Navigation API. Without it, only the
synthetic Back on close is suppressed. Submitting on a touch-only device waits
for the sheet's entry to leave before calling onSubmit, including when a tap
on Send has already closed the list. With a mouse or trackpad, an outside click
closes the list and then acts normally. Emoji suggestions keep their floating list.
When changing a controlled draft outside the composer, use
reconcileChatMentions(before, after, mentions) from @k2b/ui to update its
reference ranges alongside the text. Edits before a reference shift its offsets;
edits overlapping a reference remove its identity. The helper treats the changed
span between the shared prefix and suffix as one replacement.
import { reconcileChatMentions, type ChatMention } from "@k2b/ui";
function replaceDraft(before: string, after: string, mentions: readonly ChatMention[]) {
return { text: after, mentions: reconcileChatMentions(before, after, mentions) };
}Conversations between people
Set variant="conversation" for messages between people, for example under a
VirtualFeed of
message rows. Without it, the composer keeps
the assistant prompt's behavior.
The conversation composer is one row without a card: an attach button with a
plain plus, a quiet pill-shaped field, and a round Send button. The field is muted at rest
and shows the surface with a ring while it has focus; only its paint changes.
Formatting, emoji, and the microphone sit inside the field at its end. Send is
dimmed until there is something to send. With fileSelection alone, the
attach button opens the file choice directly; with menuActions, it opens the
add menu. With addOpensCommands and commands available, + opens the
command list and its search field instead; offer file selection as an
attachFiles row. The footer props of the assistant prompt (models, modelDetails,
contextUsage, contextActions, contextPopupAction, footerTools,
submitTools, footerContent) have no place in this row and are not rendered.
- Size: the field starts with one line and grows with its text up to a
third of the nearest size container, then scrolls. Give the conversation's
frame
container-type: size; without one, the cap is a third of the small viewport height. A hidden copy measures the text, so the field never collapses for a frame and the feed above keeps the newest message in place. A placeholder longer than the empty field stays on its one line and ends with an ellipsis. - Hint line: one line of fixed height above the field, always reserved,
so a hint never moves the field. Pass
hintfor short application notes, such as what people without access see after a reference was attached. Longer text ends with an ellipsis. The default variant shows the line only while it has content. - Enter:
sendKey="enter"(the default) sends on Enter and breaks the line on Shift+Enter;sendKey="mod-enter"breaks the line on Enter. Ctrl/⌘+Enter always sends, also while command suggestions are open; there, Enter and Tab pick a suggestion. In both modes, Enter breaks the line while the caret is inside an open fenced code block (with backticks or tildes, also in a quote) and on touch-only devices, where Send sends. It never sends during IME input, including the Enter that confirms a conversion. The application stores the person's choice. - Focus: pressing Send or another composer button keeps the field
focused, so a phone's keyboard stays open. The field also stays editable
while an asynchronous
onSubmitruns. If the submission is rejected, the draft comes back in front of anything typed in the meantime.
sendKey also works without the conversation variant: "mod-enter" there
breaks the line on Enter and sends on Ctrl/⌘+Enter.
Formatting, emoji, and microphone
These work in both variants. In the conversation variant they sit inside the field; in the default variant, in the footer.
formattingadds "Aa". It opens one short row of fixed height with Bold, Italic, and Bullet list, which is all a chat message needs; other Markdown, such as code, quotes, or links, is typed and still renders. In the conversation variant the row sits above the field and starts at the field's left edge, not at the attach button; in the default variant it sits above the footer. The composer grows upward by that row, like a new line of text, so the field and every control stay where they are and a feed that follows the newest message keeps it in view. On a coarse pointer each button is 2.75rem. The buttons write Markdown into the field, keep the selection, and remove their formatting on a second press. Ctrl/⌘+B, Ctrl/⌘+I, and Ctrl/⌘+Shift+8 do the same whileformattingis set; other shortcuts keep their browser meaning.emojiadds an emoji button.onOpenreceives the button asanchorand aninsertfunction that puts the chosen text at the caret; openEmojiPicker.Popoverthere. Touch-only devices hide the button, because their keyboard has emoji. It also completes shortcodes: a colon after a space or at the start, then two letters, such as:thor:dau, lists up to eight emoji whose GitHub shortcode, English or German name, or keyword starts with them, above the field. Emoticons such as:DD,:-D, or:-Pmatch nothing and send as typed; a sign counts only where a shortcode starts with it, as in:+1. Up and Down choose, a plain Enter or Tab inserts, and Escape closes the list; Shift+Enter still breaks the line and Ctrl/⌘+Enter still sends. Typing the closing colon of a known shortcode, such as:tada:, turns it into 🎉. Inline code, fenced code blocks, URLs, and times such as10:30stay text, and the browser's undo brings the shortcode back. Emoji with skin tones come inskinTone, andonPickreports every emoji the completion inserted, for example to remember it withrememberEmoji. The emoji data loads with the first colon typed; a shortcode closed before it arrived turns into its emoji when it arrives, unless the draft changed in the meantime.microphoneadds a microphone before Send, or at the end of the field in the conversation variant. Dictation comes first: a tap callsonDictate, which starts or stops the application's live dictation. Holding the microphone for half a second callsonVoiceMessageinstead, unless the composer was disabled during the press. The small menu next to it offers both, so keyboard and screen reader users reach each action. WithoutonDictate, a tap opens that menu; a key press also moves the focus into it. OmitonVoiceMessagewhere the browser cannot record.
Pass the dictation state as microphone.dictation. The composer shows it at
fixed places: the microphone turns into Stop while listening and waits
while refining, and the hint line says what happens. With refined,
the hint line offers "Restore original", which calls onRestoreOriginal and is
disabled whenever the microphone is.
unrefined and interrupted keep the text and say so. The application owns
the audio, the transcription, the text it writes into the draft, and when a
state ends; the composer never records or sends anything itself.
<Chat.Composer
variant="conversation"
value={draft()}
onValueChange={setDraft}
onSubmit={({ text }) => send(text)}
sendKey={preferences.sendKey}
formatting
emoji={{ onOpen: setEmojiTarget, skinTone: tone(), onPick: remember }}
microphone={{
onDictate: dictation.toggle,
onVoiceMessage: recordVoiceMessage,
dictation: dictation.state(),
onRestoreOriginal: dictation.restore,
}}
hint={hint()}
placeholder="Message Workshop"
/>
<EmojiPicker.Popover
anchor={emojiTarget()?.anchor}
recent={recent()}
skinTone={tone()}
onSkinToneChange={setTone}
onPick={(emoji) => {
emojiTarget()?.insert(emoji);
remember(emoji);
}}
onClose={() => setEmojiTarget(undefined)}
/>