Components

Comments

Per-record comment threads.

A right-hand comment panel — measured live from app.attio.com's own per-record comments panel (open a record, click the speech-bubble icon; the same panel also opens from a table row's own hover icon). A thread list or a single narrowed thread, each comment with hover actions (react, and a "⋯" menu that holds reply and resolve), a filter menu (Show resolved comments / Only my threads), a delete-confirmation modal and a composer with "@" mention support. In the list view each thread is a card and the composer is its own box after the last card, inside the same scroll area; in a thread the reply composer is pinned under a divider. See CommentCountBadge for the row's own comment-count affordance — a separate piece, since it lives in a table cell this component doesn't own.

Fully controlled: CommentsPanel renders nothing on its own beyond the threads you pass and the callbacks you wire (onPost, onReact, onResolve, onDelete, …). crisp has no comment data model of its own — reactions, replies and "mine"/"resolved" bookkeeping are the consumer's.

Available since@crisp-ui-kit/crisp@0.54.4

Installation

npm install @crisp-ui-kit/crisp

Usage

import { CommentsPanel } from "@crisp-ui-kit/crisp";

<CommentsPanel
  threads={threads}
  currentUserId={me.id}
  subject="Google"
  onPost={(text, threadId) => post(text, threadId)}
  onReact={(threadId, commentId, emoji) => react(threadId, commentId, emoji)}
  onResolve={(threadId) => resolve(threadId)}
  onDelete={(threadId, commentId) => remove(threadId, commentId)}
  onClose={closePanel}
/>;

Reply threads

Choosing Reply from a comment's "⋯" menu narrows the panel to that thread (the header switches from All comments to Comments, with a "Comment on …" breadcrumb) and posts from the composer land as replies (onPost's second argument is the open thread's id, not null).

Every comment element carries id="comment-<commentId>" and data-comment-id, so a permalink can scroll to it. Setting a data-linked attribute on that element (from your own permalink code) draws a 2px brand-tinted ring around it. onCopyLink still decides what "Copy link" copies.

canDelete(threadId, commentId) decides per comment whether the "⋯" menu offers Delete comment (only consulted when onDelete is given; omitted means every comment, as before).

<CommentsPanel
  threads={threads}
  currentUserId={me.id}
  currentUser={me}
  onPost={post}
  onDelete={remove}
  canDelete={(threadId, commentId) => commentAuthor(commentId) === me.id}
/>

currentUser supplies the avatar beside the composer; without it the panel looks for the author with id === currentUserId in threads.

Editing a comment

onEditSave(commentId, body) turns the "⋯" menu's Edit item on. canEdit(threadId, commentId) decides per comment whether it is offered (only consulted when onEditSave is given; omitted means every comment). Picking Edit swaps that comment's body for an inline editor: the header stays, the text is borderless in the body's own type, and Cancel / Save sit under it. Esc cancels, Cmd/Ctrl+Enter saves, and an empty text disables Save. Plain Enter is a line break.

The editor starts from editText on the comment, a plain string with each mention written as @[label](id), the same token onPost gets. body is a ReactNode, so crisp cannot read the raw text back out of it. A string body is used when editText is omitted.

The edit state is optional. Leave editingId out and the panel tracks the open editor itself. Pass it to own the state: onEditStart(commentId) fires when Edit is picked, and the editor opens only when your editingId matches. onEditSave and onEditCancel(commentId) fire on Save and on Cancel or Esc; in controlled mode, clear editingId in your handler.

<CommentsPanel
  threads={threads}
  currentUserId={me.id}
  onPost={post}
  canEdit={(threadId, commentId) => commentAuthor(commentId) === me.id}
  editingId={editingId}
  onEditStart={setEditingId}
  onEditSave={(commentId, body) => {
    save(commentId, body);
    setEditingId(null);
  }}
  onEditCancel={() => setEditingId(null)}
/>

Read-only viewers

Pass readOnly when the current user may read comments but not write them. The composer, every reply item in the "⋯" menu, the reply composer inside an opened thread, the hover React trigger and the reactions row's add button are not rendered; the thread list, existing reactions (inert), Copy link, the filter menu and thread navigation still work. onPost stays required for type compatibility but is never called.

<CommentsPanel threads={threads} currentUserId={me.id} onPost={() => {}} readOnly />

Filtering

Resolved threads drop out of the default list — pass showResolved/onShowResolvedChange and onlyMine/onOnlyMineChange to wire the panel's own filter menu; both are controlled the same way as threads itself. The "Show resolved comments" item only appears when onResolve is passed.

Mentions

renderMentionPicker takes the same shape RichTextEditor's own mention trigger does ({ query, anchor, select, close }), so a picker built for one works for the other.

Reactions

The reaction picker (search + a small emoji grid) is a minimal, crisp-owned control — pass emojiOptions to swap in a full emoji set; crisp ships no emoji library of its own.

CommentCountBadge

The row's own comment-count affordance (a persistent dot once a record has any comments, replaced on hover by an icon + count, plus a second "open preview" icon when given onOpenPreview) — a standalone piece so it can be dropped into a DataTable/RecordsTable cell without pulling in the panel.

import { CommentCountBadge } from "@crisp-ui-kit/crisp";

<CommentCountBadge
  count={row.commentCount}
  hovered={rowIsHovered}
  onOpen={() => openComments(row.id)}
  onOpenPreview={() => openPreview(row.id)}
/>;

Props

Prop

Type

On this page