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.
Installation
npm install @crisp-ui-kit/crispUsage
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).
Permalinks and per-comment delete
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