Comment Thread
Append-only Detail activity with newest-first flat feed, Shared / Internal visibility, and host extensibility slots.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/comment. It pulls Rich Text and Rich Text Editor.
Comment Thread is the Detail-page activity region: system events, decisions, and comments with an optional composer. By default the feed is newest on top, with the composer above a flat list (row separators — not per-message cards). Compose CommentThread + CommentComposer (and presentational CommentItem). Extend with metadata plus renderAfterBody / renderItem / renderActions without forking the shell. Bodies use Rich Text; authoring uses Rich Text Editor. See Approval And Case Patterns.
When To Use
- When a Detail or approval page needs an append-only activity stream (submit, return, comment, approve).
- When reviewers and counterparties share a dialogue, and some notes must stay internal to staff.
- When return / decline requires a written reason before the decision can complete.
- When the thread region must show Loading / Empty / Error for its own fetch (Page And Region Status).
- Prefer Message Scroller when the surface is streaming agent chat, not pack activity.
- Prefer Timeline for a read-only audit tab (vertical rail, newest first) without a composer.
Features
| Area | Behavior |
|---|---|
| Message kinds | system · comment · decision (decision chip tones by outcome) |
| Sort | Default desc (newest first, composer above); opt-in asc (oldest first, composer below) |
| Row chrome | Flat feed rows with divide-y — not a card per message |
| Visibility | shared (default) · internal — Internal chip on the row |
| Avatar | Optional (showAvatar); initials when on and no avatarUrl |
| Async triad | status idle / loading / error when there are no usable messages |
| Empty | Default “No activity yet”; override title / description |
| Composer | Slot for top-level writing; default CommentComposer = unboxed Send + optional Shared / Internal |
| Body HTML | Sanitized HTML via Rich Text |
| Metadata | Optional opaque metadata bag — kit never reads keys |
| Extensibility | renderAfterBody · renderItem · renderActions · replace composer |
Interactions
| Event | Behavior |
|---|---|
| Send | Calls composer onSubmit({ bodyHtml, visibility }); disabled when required and empty |
| Shared / Internal | Shown only when showVisibilityChooser; default shared |
| Retry | Host onRetry on the error Result |
| Return / decline | Use the composer without the chooser so the reason stays Shared |
| Pin / chips / custom rows | Host-only via render slots — kit does not ship Reply or Delete |
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/commentFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/commentFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/commentFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/commentAlso works via URL: https://ui.isaacfei.com/api/plus/r/comment.json.
registryDependencies: @f-ui-plus/rich-text, @f-ui-plus/rich-text-editor, https://ui.isaacfei.com/r/result.json, @f-ui/status-tag, @f-ui/empty, button, avatar, skeleton.
Usage
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
function handleSubmit({ bodyHtml, visibility }) {
/* append to host state / API */
}
<CommentThread
messages={messages}
showAvatar={false}
composer={
<CommentComposer
required
placeholder="Add a comment…"
onSubmit={handleSubmit}
/>
}
/>Examples
Approval Activity
Seeded system → return → internal staff note → requester comment → approve. Newest activity sits on top; type in the composer and Send to append. Toggle Demo state for Empty / Loading / Error.
Demo state
Activity
Looks good — approved.
Updated line qty to match the quote. Ready for re-review.
Flagged for audit — vendor quote looked off; do not mention to the requester yet.
Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.
Purchase request PO-2026-0841 submitted for review.
"use client";
import { useState } from "react";
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import type { CommentMessageView } from "@/components/f-ui/comment/comment-types";
import { Button } from "@/components/ui/button";
import {
COMMENT_APPROVAL_SEED,
appendLocalComment,
} from "./comment-demo-data";
type DemoView = "populated" | "empty" | "loading" | "error";
const VIEWS: { id: DemoView; label: string }[] = [
{ id: "populated", label: "Populated" },
{ id: "empty", label: "Empty" },
{ id: "loading", label: "Loading" },
{ id: "error", label: "Error" },
];
/**
* Full approval path + Loading / Empty / Error triad.
* Copy: host-branch status; composer above the list (newest first); append on Send.
*/
export function CommentThreadDemo() {
const [view, setView] = useState<DemoView>("populated");
const [messages, setMessages] =
useState<CommentMessageView[]>(COMMENT_APPROVAL_SEED);
const status =
view === "loading" ? "loading" : view === "error" ? "error" : "idle";
const listMessages = view === "populated" ? messages : [];
const showComposer = view === "populated" || view === "empty";
function handleViewChange(next: DemoView) {
setView(next);
if (next === "empty") {
setMessages([]);
} else if (next === "populated" && messages.length === 0) {
setMessages(COMMENT_APPROVAL_SEED);
}
}
function handleSubmit(payload: Parameters<typeof appendLocalComment>[1]) {
setMessages((prev) =>
appendLocalComment(prev, payload, {
authorLabel: "You",
roleLabel: "Requester",
}),
);
setView("populated");
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">Demo state</p>
{VIEWS.map(({ id, label }) => (
<Button
key={id}
type="button"
variant={view === id ? "default" : "outline"}
onClick={() => handleViewChange(id)}
>
{label}
</Button>
))}
</div>
<div className="rounded-xl border bg-card p-4">
<div className="mb-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
</div>
<CommentThread
messages={listMessages}
status={status}
errorMessage="Could not load activity"
onRetry={() => handleViewChange("populated")}
emptyTitle="No activity yet"
emptyDescription="Comments and decisions for this pack will appear here."
composer={
showComposer ? (
<CommentComposer
required
placeholder="Add a comment…"
submitLabel="Send"
showVisibilityChooser
onSubmit={handleSubmit}
/>
) : undefined
}
/>
</div>
</div>
);
}Shared And Internal Visibility
Toggle View as Finance vs Requester. Finance sees the Internal chip and can post Internal notes. Requester receives a filtered list — Internal never reaches the kit. Copy this host-filter pattern into persona policy.
View as
Internal rows show an Internal chip. Use Shared / Internal before Send.
Looks good — approved.
Updated line qty to match the quote. Ready for re-review.
Flagged for audit — vendor quote looked off; do not mention to the requester yet.
Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.
Purchase request PO-2026-0841 submitted for review.
"use client";
import { useMemo, useState } from "react";
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import type { CommentMessageView } from "@/components/f-ui/comment/comment-types";
import { Button } from "@/components/ui/button";
import {
COMMENT_APPROVAL_SEED,
appendLocalComment,
} from "./comment-demo-data";
type Seat = "approver" | "requester";
/**
* JSM-class visibility: approver sees Internal notes; requester list is filtered
* before it hits CommentThread (never CSS-hide secrets).
*/
export function CommentVisibilityDemo() {
const [seat, setSeat] = useState<Seat>("approver");
const [messages, setMessages] =
useState<CommentMessageView[]>(COMMENT_APPROVAL_SEED);
const visible = useMemo(() => {
if (seat === "approver") return messages;
return messages.filter((m) => (m.visibility ?? "shared") !== "internal");
}, [messages, seat]);
const isApprover = seat === "approver";
function handleSubmit(payload: Parameters<typeof appendLocalComment>[1]) {
setMessages((prev) =>
appendLocalComment(prev, payload, {
authorLabel: isApprover ? "You" : "Jordan Lee",
roleLabel: isApprover ? "Finance" : "Requester",
}),
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">View as</p>
<Button
type="button"
variant={isApprover ? "default" : "outline"}
onClick={() => setSeat("approver")}
>
Finance (sees internal)
</Button>
<Button
type="button"
variant={!isApprover ? "default" : "outline"}
onClick={() => setSeat("requester")}
>
Requester (shared only)
</Button>
</div>
<p className="text-muted-foreground text-sm">
{isApprover
? "Internal rows show an Internal chip. Use Shared / Internal before Send."
: "Host filtered out Internal rows — the kit never received them."}
</p>
<div className="rounded-xl border bg-card p-4">
<CommentThread
messages={visible}
showAvatar={false}
emptyTitle="No activity yet"
composer={
<CommentComposer
required
placeholder={
isApprover
? "Shared update or internal note…"
: "Add a comment…"
}
submitLabel="Send"
showVisibilityChooser={isApprover}
onSubmit={handleSubmit}
/>
}
/>
</div>
</div>
);
}Sort Order
Toggle Newest first (Activity default — composer above) vs Oldest first (conversation — composer below).
Sort
Activity class: composer above the list.
Looks good — approved.
Updated line qty to match the quote. Ready for re-review.
Flagged for audit — vendor quote looked off; do not mention to the requester yet.
Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.
Purchase request PO-2026-0841 submitted for review.
"use client";
import { useState } from "react";
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import type { CommentSortOrder } from "@/components/f-ui/comment/comment-types";
import { Button } from "@/components/ui/button";
import {
COMMENT_APPROVAL_SEED,
appendLocalComment,
} from "./comment-demo-data";
/**
* Toggle Activity `desc` (newest first, composer above) vs conversation `asc`.
*/
export function CommentSortOrderDemo() {
const [sortOrder, setSortOrder] = useState<CommentSortOrder>("desc");
const [messages, setMessages] = useState(COMMENT_APPROVAL_SEED);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">Sort</p>
<Button
type="button"
variant={sortOrder === "desc" ? "default" : "outline"}
onClick={() => setSortOrder("desc")}
>
Newest first (default)
</Button>
<Button
type="button"
variant={sortOrder === "asc" ? "default" : "outline"}
onClick={() => setSortOrder("asc")}
>
Oldest first
</Button>
</div>
<p className="text-muted-foreground text-sm">
{sortOrder === "desc"
? "Activity class: composer above the list."
: "Conversation class: composer below the list."}
</p>
<div className="rounded-xl border bg-card p-4">
<CommentThread
messages={messages}
sortOrder={sortOrder}
showAvatar={false}
composer={
<CommentComposer
required
placeholder="Add a comment…"
onSubmit={(payload) => {
setMessages((prev) =>
appendLocalComment(prev, payload, {
authorLabel: "You",
roleLabel: "Requester",
}),
);
}}
/>
}
/>
</div>
</div>
);
}Metadata And Host Actions
Return reason carries metadata.label. Host renders a chip with renderAfterBody and a Pin control with renderActions. The kit does not interpret metadata keys.
Activity
Return row carries metadata.label; host renders a chip. Pin is host renderActions.
Looks good — approved.
Updated line qty to match the quote. Ready for re-review.
Flagged for audit — vendor quote looked off; do not mention to the requester yet.
Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.
Purchase request PO-2026-0841 submitted for review.
"use client";
import { useState } from "react";
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import type { CommentMessageView } from "@/components/f-ui/comment/comment-types";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import {
COMMENT_APPROVAL_SEED,
appendLocalComment,
} from "./comment-demo-data";
const WITH_METADATA: CommentMessageView[] = COMMENT_APPROVAL_SEED.map((m) =>
m.id === "m2"
? {
...m,
metadata: {
targetType: "line",
targetId: "sku-881",
label: "Line SKU-881",
},
}
: m,
);
/**
* metadata + renderAfterBody chip + renderActions — kit never reads metadata keys.
*/
export function CommentExtensibilityDemo() {
const [messages, setMessages] = useState(WITH_METADATA);
const [pinned, setPinned] = useState<string | null>(null);
return (
<div className="rounded-xl border bg-card p-4">
<div className="mb-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
<p className="text-muted-foreground mt-1 text-sm">
Return row carries <code className="text-xs">metadata.label</code>;
host renders a chip. Pin is host <code className="text-xs">renderActions</code>.
</p>
</div>
<CommentThread
messages={messages}
renderAfterBody={(message) => {
const label = message.metadata?.label;
if (typeof label !== "string" || !label) return null;
return <StatusTag tone="neutral">{label}</StatusTag>;
}}
renderActions={(message) => (
<Button
type="button"
variant="ghost"
className="h-7 px-2 text-muted-foreground"
onClick={() =>
setPinned((id) => (id === message.id ? null : message.id))
}
>
{pinned === message.id ? "Unpin" : "Pin"}
</Button>
)}
composer={
<CommentComposer
required
placeholder="Add a comment…"
showVisibilityChooser
onSubmit={(payload) => {
setMessages((prev) =>
appendLocalComment(prev, payload, {
authorLabel: "You",
roleLabel: "Requester",
}),
);
}}
/>
}
/>
{pinned ? (
<p className="text-muted-foreground mt-3 text-sm">
Pinned id: <span className="font-medium text-foreground">{pinned}</span>
</p>
) : null}
</div>
);
}Custom Rows
metadata.type === "notice" replaces the row via renderItem with a host policy callout. Other rows wrap defaultItem so you keep kit anatomy. Bordered custom rows should pass listClassName="gap-3 divide-y-0" so rows are not flush against the default dividers.
Activity
Newest row is a host policy callout via renderItem; comments keep kit anatomy inside a light wrap.
Policy update
PolicyVendor quotes over CNY 50,000 need a second Finance signature before resubmit.
Normal comment keeps the default CommentItem.
Wrapped with a host border via defaultItem.
"use client";
import { MegaphoneIcon } from "lucide-react";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import type { CommentMessageView } from "@/components/f-ui/comment/comment-types";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
const MESSAGES: CommentMessageView[] = [
{
id: "c2",
at: "2026-08-04T12:00:00.000Z",
authorLabel: "Alex Chen",
roleLabel: "Finance",
kind: "comment",
visibility: "shared",
bodyHtml: "<p>Wrapped with a host border via defaultItem.</p>",
},
{
id: "c1",
at: "2026-08-04T13:00:00.000Z",
authorLabel: "Jordan Lee",
roleLabel: "Requester",
kind: "comment",
visibility: "shared",
bodyHtml: "<p>Normal comment keeps the default CommentItem.</p>",
},
{
id: "n1",
at: "2026-08-04T15:30:00.000Z",
authorLabel: "Policy",
kind: "system",
visibility: "shared",
bodyHtml:
"<p>Vendor quotes over <strong>CNY 50,000</strong> need a second Finance signature before resubmit.</p>",
metadata: {
type: "notice",
title: "Policy update",
hrefLabel: "View policy",
},
},
];
/**
* renderItem ladder: replace notice rows with a host callout; wrap others.
*/
export function CommentCustomRowDemo() {
return (
<div className="rounded-xl border bg-card p-4">
<div className="mb-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
<p className="text-muted-foreground mt-1 text-sm">
Newest row is a host{" "}
<span className="font-medium text-foreground">policy callout</span> via{" "}
<code className="text-xs">renderItem</code>; comments keep kit anatomy
inside a light wrap.
</p>
</div>
<CommentThread
messages={MESSAGES}
showAvatar={false}
listClassName="gap-3 divide-y-0"
renderItem={({ message, defaultItem }) => {
if (message.metadata?.type === "notice") {
const title =
typeof message.metadata.title === "string"
? message.metadata.title
: "Notice";
const hrefLabel =
typeof message.metadata.hrefLabel === "string"
? message.metadata.hrefLabel
: "Learn more";
return (
<div
data-slot="comment-custom-notice"
className="border-info/25 bg-info/10 overflow-hidden rounded-xl border shadow-sm"
>
<div className="from-info/25 h-1 bg-gradient-to-r via-info/10 to-transparent" />
<div className="flex gap-3 p-3 sm:gap-4 sm:p-4">
<div
className="bg-background text-info border-info/20 flex size-10 shrink-0 items-center justify-center rounded-xl border shadow-sm"
aria-hidden
>
<MegaphoneIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-foreground">
{title}
</p>
<StatusTag tone="info">Policy</StatusTag>
<time
className="text-muted-foreground ms-auto text-xs tabular-nums"
dateTime={message.at}
>
Just now
</time>
</div>
{message.bodyHtml ? (
<RichText
value={message.bodyHtml}
className="text-muted-foreground mt-1.5 text-sm leading-relaxed"
/>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button type="button">
{hrefLabel}
</Button>
<Button type="button" variant="outline">
Dismiss
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="bg-card rounded-lg px-1 ring-1 ring-border/60 ring-inset">
{defaultItem}
</div>
);
}}
/>
</div>
);
}Compact Activity Without Avatars
Same feed with showAvatar={false} for dense Detail sidebars. Prefer this when faces add noise.
Activity
Looks good — approved.
Updated line qty to match the quote. Ready for re-review.
Flagged for audit — vendor quote looked off; do not mention to the requester yet.
Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.
Purchase request PO-2026-0841 submitted for review.
"use client";
import { CommentThread } from "@/components/f-ui/comment/comment-thread";
import { COMMENT_APPROVAL_SEED } from "./comment-demo-data";
/**
* Dense Detail Activity: no avatar column (Ant optional avatar).
* Copy when the region is narrow or faces are noise.
*/
export function CommentCompactDemo() {
return (
<div className="rounded-xl border bg-card p-4">
<div className="mb-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
</div>
<CommentThread
messages={COMMENT_APPROVAL_SEED}
showAvatar={false}
/>
</div>
);
}Required Return Reason
Open the dialog, leave the editor empty — Send stays disabled. Type a reason and submit. No visibility chooser: return / decline reasons stay Shared so the counterparty can read them.
Open the dialog, leave empty — Send stays disabled. Type a reason, then Return request.
"use client";
import { useState } from "react";
import { CommentComposer } from "@/components/f-ui/comment/comment-composer";
import type { CommentSubmitPayload } from "@/components/f-ui/comment/comment-types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/**
* Return / decline gate: required body, Shared only (counterparty must read it).
* Copy into your decision Dialog — do not enable visibility chooser here.
*/
export function CommentRequiredComposerDemo() {
const [open, setOpen] = useState(false);
const [last, setLast] = useState<CommentSubmitPayload | null>(null);
return (
<div className="flex flex-col gap-3">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Return request…
</Button>
{last ? (
<p className="text-muted-foreground text-sm">
Last submit visibility: <strong>{last.visibility}</strong> — body
length {last.bodyHtml.replace(/<[^>]*>/g, "").trim().length} chars
</p>
) : (
<p className="text-muted-foreground text-sm">
Open the dialog, leave empty — Send stays disabled. Type a reason,
then Return request.
</p>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle>Return request</DialogTitle>
<DialogDescription>
A reason is required before returning to the requester. This
comment is always Shared.
</DialogDescription>
</DialogHeader>
<CommentComposer
required
placeholder="Explain what the requester should fix"
submitLabel="Return request"
onSubmit={(payload) => {
setLast(payload);
setOpen(false);
}}
/>
</DialogContent>
</Dialog>
</div>
);
}Extensibility
Three host rungs — use the lowest that fits:
| Rung | Prop | Use when |
|---|---|---|
| 1 | renderAfterBody | Chips, attachment strips, quotes under the body |
| 2 | renderActions | Pin, copy link, host tools (not kit Reply/Delete) |
| 3 | renderItem | Replace or wrap the whole row (defaultItem is the stock CommentItem) |
Domain facts go in message.metadata. Put line anchors there too (for example { targetType: "line", targetId, label }) — there is no kit targetRef field.
<CommentThread
messages={messages}
renderAfterBody={(m) =>
typeof m.metadata?.label === "string" ? (
<StatusTag tone="neutral">{m.metadata.label}</StatusTag>
) : null
}
renderItem={({ message, defaultItem }) =>
message.metadata?.type === "notice" ? <NoticeCard /> : defaultItem
}
composer={<CommentComposer required onSubmit={handleSubmit} />}
/>Composer Escape
Need Formily multi-field authoring, custom validation, or a different editor? Pass your own node as composer. The default CommentComposer is optional sugar (RTE + Shared/Internal + Send), not a sealed pipeline.
Composition
CommentThread
├── composer? — above when sortOrder is desc (default)
├── body
│ ├── Skeleton | Result + Retry | Empty
│ └── rows (sorted)
│ └── renderItem? → default CommentItem
│ ├── avatar? | author · role · decision · Internal · time
│ ├── bodyHtml (RichText)
│ ├── renderAfterBody?
│ └── renderActions?
└── composer? — below when sortOrder is ascEdge Cases & Errors
Internal Visibility Is Host Policy
The Internal chip is presentation only. External / partner seats must filter visibility: "internal" out of messages before rendering CommentThread — never CSS-hide secrets. See Shared And Internal Visibility above.
Required Shared Reasons
Return and decline dialogs use required on the composer and keep showVisibilityChooser off so the counterparty always receives the reason. See Required Return Reason.
Not A Chat Transcript
Streaming agent chat belongs on Message Scroller. Comment Thread is reverse-chrono object activity — a flat journal with a writing slot.
Comment Thread Vs Timeline
| Need | Use |
|---|---|
| Writable Detail Activity (post comments / decisions) | Comment Thread |
| Read-only audit rail (no composer) | Timeline |
No Kit Reply Or Delete
Approval Activity is append-only and flat. Use the top composer for the next journal line. Extra row tools = renderActions only.
Not Per-Message Cards
Rows stay feed items with separators. A single Card around the Activity region (as in demos) is fine — do not wrap each CommentItem in its own Card.
Metadata Is Opaque
The kit never reads metadata keys. If nothing appears for your metadata, you forgot a render slot — that is intentional.
FAQ
Why is there no Reply under each comment?
Activity is a journal with one writing entry (top composer), not a social thread. Nested Reply competed with the composer and mixed poorly with system/decision rows.
Why no Delete or Edit?
Submitted approval comments are audit trail. Hosts that need collab tools use renderActions — not shown in Activity demos.
Where do I put a line / SKU anchor?
In metadata (for example label, targetId). Render it with renderAfterBody. A typed kit targetRef is intentionally omitted so we do not grow a second extension channel.
When do I use renderAfterBody vs renderItem?
Decorate under the body → renderAfterBody. Need a wholly different row (notice, graph, poll) or a wrapper ring → renderItem. Prefer wrapping defaultItem when you still want kit anatomy.
Can I use Formily for the composer?
Yes — pass a Formily form as the composer slot. Do not expect the default CommentComposer to become Formily.
How do I keep oldest-first chat order?
sortOrder="asc" — composer moves below the list.
Does the kit persist messages?
No. Host owns fetch, append, permissions, and filtering.
Will Internal notes leak if I only hide them with CSS?
Yes. Filter them out of messages before they reach the kit.
API Reference
CommentThread Props
| Prop | Type | Default | Description |
|---|---|---|---|
messages | CommentMessageView[] | — | Flat list; kit sorts by at per sortOrder |
sortOrder | "desc" | "asc" | "desc" | desc = newest first + composer above; asc = conversation + composer below |
status | "idle" | "loading" | "error" | "idle" | Async branch when there are no usable messages |
errorMessage | string | "Could not load activity" | Result title on error |
onRetry | () => void | — | Retry control on the error Result |
emptyTitle | string | "No activity yet" | Empty title |
emptyDescription | string | — | Empty description |
renderAfterBody | (message) => ReactNode | — | After body RichText (chips, attachments) |
renderActions | (message) => ReactNode | — | Host row tools; kit does not ship Reply/Delete/Edit |
renderItem | (ctx: { message, defaultItem }) => ReactNode | — | Wrap or replace the default row |
composer | ReactNode | — | Writing slot (placement follows sortOrder) |
showAvatar | boolean | true | Forwarded to each CommentItem |
listClassName | string | — | Extra classes on the message list (e.g. gap-3 divide-y-0 when rows have their own borders) |
className | string | — | Root class |
CommentComposer Props
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (payload: CommentSubmitPayload) => void | — | { bodyHtml, visibility } on Send |
required | boolean | false | Disables Send while the editor is empty |
disabled | boolean | false | Disables editor and Send |
placeholder | string | — | Editor placeholder / aria-label |
submitLabel | string | "Send" | Send button label |
showVisibilityChooser | boolean | false | Shared / Internal control |
defaultVisibility | "shared" | "internal" | "shared" | Initial chooser value |
className | string | — | Root class |
CommentItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
message | CommentMessageView | — | Row model |
showAvatar | boolean | true | Avatar column |
afterBody | ReactNode | — | After RichText (Thread maps renderAfterBody here) |
actions | ReactNode | — | Action row (Thread maps renderActions here) |
className | string | — | Root class |
Types
| Type | Fields | Description |
|---|---|---|
CommentMessageView | id, at, authorLabel, avatarUrl?, roleLabel?, kind, decisionLabel?, visibility?, bodyHtml?, metadata? | Row model; metadata is host-opaque |
CommentSubmitPayload | bodyHtml, visibility | Composer submit payload |
CommentVisibility | "shared" | "internal" | Audience; default shared when omitted on a message |
CommentSortOrder | "desc" | "asc" | Sort; default desc |