f-ui
Components

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

AreaBehavior
Message kindssystem · comment · decision (decision chip tones by outcome)
SortDefault desc (newest first, composer above); opt-in asc (oldest first, composer below)
Row chromeFlat feed rows with divide-ynot a card per message
Visibilityshared (default) · internal — Internal chip on the row
AvatarOptional (showAvatar); initials when on and no avatarUrl
Async triadstatus idle / loading / error when there are no usable messages
EmptyDefault “No activity yet”; override title / description
ComposerSlot for top-level writing; default CommentComposer = unboxed Send + optional Shared / Internal
Body HTMLSanitized HTML via Rich Text
MetadataOptional opaque metadata bag — kit never reads keys
ExtensibilityrenderAfterBody · renderItem · renderActions · replace composer

Interactions

EventBehavior
SendCalls composer onSubmit({ bodyHtml, visibility }); disabled when required and empty
Shared / InternalShown only when showVisibilityChooser; default shared
RetryHost onRetry on the error Result
Return / declineUse the composer without the chooser so the reason stays Shared
Pin / chips / custom rowsHost-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/comment
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/comment
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/comment
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/comment

Also 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

AC
Alex ChenFinanceApproved

Looks good — approved.

JL
Jordan LeeRequester

Updated line qty to match the quote. Ready for re-review.

AC
Alex ChenFinanceInternal

Flagged for audit — vendor quote looked off; do not mention to the requester yet.

AC
Alex ChenFinanceReturned

Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.

SY
System

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.

Alex ChenFinanceApproved

Looks good — approved.

Jordan LeeRequester

Updated line qty to match the quote. Ready for re-review.

Alex ChenFinanceInternal

Flagged for audit — vendor quote looked off; do not mention to the requester yet.

Alex ChenFinanceReturned

Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.

System

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.

Alex ChenFinanceApproved

Looks good — approved.

Jordan LeeRequester

Updated line qty to match the quote. Ready for re-review.

Alex ChenFinanceInternal

Flagged for audit — vendor quote looked off; do not mention to the requester yet.

Alex ChenFinanceReturned

Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.

System

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.

AC
Alex ChenFinanceApproved

Looks good — approved.

JL
Jordan LeeRequester

Updated line qty to match the quote. Ready for re-review.

AC
Alex ChenFinanceInternal

Flagged for audit — vendor quote looked off; do not mention to the requester yet.

AC
Alex ChenFinanceReturned

Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.

Line SKU-881
SY
System

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

Policy

Vendor quotes over CNY 50,000 need a second Finance signature before resubmit.

Jordan LeeRequester

Normal comment keeps the default CommentItem.

Alex ChenFinance

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

Alex ChenFinanceApproved

Looks good — approved.

Jordan LeeRequester

Updated line qty to match the quote. Ready for re-review.

Alex ChenFinanceInternal

Flagged for audit — vendor quote looked off; do not mention to the requester yet.

Alex ChenFinanceReturned

Line qty on SKU-881 exceeds the approved budget. Please adjust and resubmit.

System

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:

RungPropUse when
1renderAfterBodyChips, attachment strips, quotes under the body
2renderActionsPin, copy link, host tools (not kit Reply/Delete)
3renderItemReplace 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 asc

Edge 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

NeedUse
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

PropTypeDefaultDescription
messagesCommentMessageView[]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
errorMessagestring"Could not load activity"Result title on error
onRetry() => voidRetry control on the error Result
emptyTitlestring"No activity yet"Empty title
emptyDescriptionstringEmpty description
renderAfterBody(message) => ReactNodeAfter body RichText (chips, attachments)
renderActions(message) => ReactNodeHost row tools; kit does not ship Reply/Delete/Edit
renderItem(ctx: { message, defaultItem }) => ReactNodeWrap or replace the default row
composerReactNodeWriting slot (placement follows sortOrder)
showAvatarbooleantrueForwarded to each CommentItem
listClassNamestringExtra classes on the message list (e.g. gap-3 divide-y-0 when rows have their own borders)
classNamestringRoot class

CommentComposer Props

PropTypeDefaultDescription
onSubmit(payload: CommentSubmitPayload) => void{ bodyHtml, visibility } on Send
requiredbooleanfalseDisables Send while the editor is empty
disabledbooleanfalseDisables editor and Send
placeholderstringEditor placeholder / aria-label
submitLabelstring"Send"Send button label
showVisibilityChooserbooleanfalseShared / Internal control
defaultVisibility"shared" | "internal""shared"Initial chooser value
classNamestringRoot class

CommentItem Props

PropTypeDefaultDescription
messageCommentMessageViewRow model
showAvatarbooleantrueAvatar column
afterBodyReactNodeAfter RichText (Thread maps renderAfterBody here)
actionsReactNodeAction row (Thread maps renderActions here)
classNamestringRoot class

Types

TypeFieldsDescription
CommentMessageViewid, at, authorLabel, avatarUrl?, roleLabel?, kind, decisionLabel?, visibility?, bodyHtml?, metadata?Row model; metadata is host-opaque
CommentSubmitPayloadbodyHtml, visibilityComposer submit payload
CommentVisibility"shared" | "internal"Audience; default shared when omitted on a message
CommentSortOrder"desc" | "asc"Sort; default desc

On this page