Comment thread
ProA discussion anchored to one thing in the app (a plan step, a diff line, a form field, an output), between people and agents. The header names the thing, quotes what it said, and says when it has changed or gone since ("The line this was on has changed since"), as the app reports it. Comments read as text, whoever wrote them; an agent is named by the app and marked as an agent. Replies sit under the comment they answer. The viewer can edit and delete their own comments: an edit is marked "edited", and a delete asks first and says what stays visible (afterDelete) and whether it can be brought back (deleteNote). Resolve and Reopen act on the whole thread, which then says who resolved it and when and folds its comments away.
Every action goes to the app's server through its callback, and shows as under way ("Sending…", "Saving…", "Deleting…", "Resolving…") until the data passed back says it happened. A rejected promise, or a handler that throws, puts it back and says it failed, keeping what the person wrote. Unread comments carry a mark and a count, with Mark as read when onMarkRead is given. A comment from someone else that arrives after the thread drew is announced to screen readers. With a thread's comments in focus, the up and down arrows move between them, and Home and End go to the first and last; Escape closes an edit or a reply and returns focus to the comment.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0 and 2.8.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/comment-thread
Brings in: agent-avatar, alert-dialog, avatar, bidi, button, chat, composer, data, heading-level, heading-scope, locale, motion, page-state, relative-time, textarea, tokens, use-arrivals, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { CommentThread, type CommentAuthor, type CommentResolution, type ThreadComment } from "@/components/candor/comment-thread"
const ago = (m: number) => new Date(Date.now() - m * 60_000).toISOString()
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
const ana: CommentAuthor = { person: { id: "u-ana", name: "Ana Ruiz" } }
const checker: CommentAuthor = { agent: { id: "a-review", name: "Checker", role: "reviewer" } }
const you: CommentAuthor = { person: { id: "u-sam", name: "Sam Reyes" } }
export default function Example() {
const [comments, setComments] = React.useState<ThreadComment[]>([
{ id: "c1", author: ana, text: "Should this round each line before summing? Finance rounds per line.", at: ago(42) },
{ id: "c2", author: checker, text: "Rounding per line changes 14 of last month's invoices, each by one cent.", at: ago(38), replyTo: "c1", unread: true },
{ id: "c3", author: you, text: "Then keep it as it is and round the total.", at: ago(20), replyTo: "c2", mine: true },
])
const [resolved, setResolved] = React.useState<CommentResolution | undefined>()
const next = React.useRef(4)
return (
<CommentThread
className="max-w-xl"
anchor={{
kind: "line",
id: "billing.ts:42",
label: "src/billing.ts, line 42",
quote: "const total = lines.reduce((sum, l) => sum + l.amount, 0)",
code: true,
status: "changed",
}}
comments={comments}
resolved={resolved}
viewer={you}
onComment={async (text, { replyTo }) => {
await wait(600)
const id = `c${next.current++}`
setComments((list) => [...list, { id, author: you, text, at: new Date().toISOString(), replyTo, mine: true }])
return id
}}
onEdit={async (id, text) => {
await wait(400)
setComments((list) => list.map((c) => (c.id === id ? { ...c, text, editedAt: new Date().toISOString() } : c)))
}}
onDelete={async (id) => {
await wait(400)
setComments((list) => list.map((c) => (c.id === id ? { ...c, text: "", deletedAt: new Date().toISOString() } : c)))
}}
onResolve={async () => {
await wait(400)
setResolved({ by: you, at: new Date().toISOString() })
}}
onReopen={async () => {
await wait(400)
setResolved(undefined)
}}
onMarkRead={(ids) => setComments((list) => list.map((c) => (ids.includes(c.id) ? { ...c, unread: false } : c)))}
onShowAnchor={() => {}}
/>
)
}<CommentThread>
| Prop | Type | Default | Description |
|---|---|---|---|
| anchorrequired | CommentAnchor | — | The thing the thread is on: its kind, id, label, a quote, and whether it has changed or gone. |
| commentsrequired | ThreadComment[] | — | The comments, oldest first, as the app's server has them. |
| resolved | CommentResolution | undefined | — | Set when the thread is resolved: who did it and when. The thread shows as resolved only once this says so. |
| viewer | CommentAuthor | undefined | — | The viewer, shown on their own comments while they send. Without it, "You". |
| onComment | ((text: string, context: { replyTo?: string | undefined }) => Promise<string | void> | string | void) | undefined | — | Shows the composer and Reply. Called with the text and the comment it answers. Resolve with the saved comment's id and the sending copy stays until that comment is in `comments`; resolve with nothing and it goes when the promise does. A rejection keeps the text, marked "Not sent", with Try again. |
| onEdit | ((id: string, text: string) => Outcome) | undefined | — | Shows Edit on the viewer's own comments. The new text shows as "Saving…" until `comments` has it. |
| onDelete | ((id: string) => Outcome) | undefined | — | Shows Delete on the viewer's own comments, after a confirmation. It shows as "Deleting…" until `comments` has it deleted or gone. |
| onResolve | (() => Outcome) | undefined | — | Shows Resolve on an open thread. |
| onReopen | (() => Outcome) | undefined | — | Shows Reopen on a resolved thread. |
| onMarkRead | ((ids: string[]) => void) | undefined | — | Shows Mark as read while any comment is unread; called with their ids. |
| onShowAnchor | (() => void) | undefined | — | Shows a button to go to the thing, unless it's gone. |
| afterDelete | "marked" | "removed" | undefined | "marked" | What the app's server leaves when a comment is deleted: a line in its place ("marked", the default) or nothing ("removed"). The confirmation says which. |
| deleteNote | string | undefined | — | In place of "A deleted comment can't be brought back.", when the app can restore one: "An admin can restore it for 30 days." |
| locked | string | null | undefined | — | Why the viewer can't comment, shown in place of the composer's box: "Only members of this project can comment." `null` or left out: unlocked. |
| mentions | MentionOption[] | undefined | — | What @ can insert in the composer; each inserts its label as text. A comment is text, so an option's `entity` isn't attached as a reference: its label is typed like any other. |
| presence | React.ReactNode | undefined | — | Who else is here, beside the resolve control: pass a `presence` element. |
| state | ListState | undefined | — | Loading, empty or error in place of the comments, or partial with a note; ready (or left out) shows them. |
| onRetry | (() => void) | undefined | — | Shows Try again when `state` is an error; called when it's pressed. |
| headingLevel | HeadingLevel | undefined | — | The heading level of the thread's title. Defaults to one below the nearest `HeadingScope`, else 2. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under commentThread in <LocaleProvider strings={{ … }}>; the table is exported as commentThreadStrings.
| Key | English |
|---|---|
| label | Comments on {anchor} |
| anchorChanged | The {kind} this was on has changed since. The quote is what it said then. |
| anchorGone | The {kind} this was on no longer exists. The quote is what it said then. |
| showAnchor | Show the {kind} |
| agent | {noun} agent |
| agentName | {name}, {noun} agent |
| you | You |
| edited | edited |
| deletedAt | Deleted {time} |
| reply | Reply |
| replyTo | Reply to {name} |
| inReplyTo | to {name} |
| edit | Edit |
| delete | Delete |
| save | Save |
| saving | Saving… |
| cancel | Cancel |
| editLabel | Edit your comment |
| editNote | Everyone sees the new text, marked as edited. |
| emptyEdit | Write something, or delete the comment. |
| saveFailed | Couldn’t save the edit. Your text is still here. |
| sending | Sending… |
| notSent | Not sent. |
| retry | Try again |
| discard | Discard |
| deleting | Deleting… |
| deleteFailed | Couldn’t delete the comment. |
| deleteTitle | Delete this comment? |
| deleteMarked | Its text is removed for everyone. Your name, its time and a line saying it was deleted stay in its place, and replies to it stay. |
| deleteRemoved | It is removed from the thread for everyone. Replies to it stay. |
| cantUndo | A deleted comment can’t be brought back. |
| keep | Keep it |
| resolve | Resolve |
| resolving | Resolving… |
| reopen | Reopen |
| reopening | Reopening… |
| resolvedBy | Resolved by {name} {time} |
| resolveFailed | Couldn’t resolve the thread. |
| reopenFailed | Couldn’t reopen the thread. |
| showComments | { one: "Show {count} comment", other: "Show {count} comments" } |
| hideComments | Hide comments |
| unread | Unread |
| unreadCount | { one: "{count} unread", other: "{count} unread" } |
| markRead | Mark as read |
| newComment | New comment from {name}: {text} |
| newReply | New reply from {name}: {text} |
| newComments | { one: "{count} new comment", other: "{count} new comments" } |
| placeholder | Add a comment |
| replyPlaceholder | Write a reply |
| commentLabel | Comment |
| comments | Comments |
| replies | Replies to {name} |
| empty | No comments yet. |
| loading | Loading comments |
| error | Comments couldn’t be loaded. |
Motion
| Moment | Family | What it does |
|---|---|---|
| comment | arrive, rise | A comment that arrives after the thread first drew (someone else's, or the person's own while it sends) rises in once. Comments that were there when the thread drew appear still, and under reduced motion a new one fades in. |
Set with --cd-motion-comment-thread-<moment>-* in your brand file; the families are in the design reference.
Types
- CommentAnchor
- What a thread is about: a thing in the app, by its kind and id, with a short quote of what it pointed at.
{ /** The app's noun for the thing, as it reads in a sentence: "line", "step", "field", "output". */ kind: string; /** The app's id for it. Not shown; the app uses it to find the thing again. */ id: string; /** Where it is, in the app's words: "Step 3: Back up the table", "src/billing.ts, line 42". Without it, the kind. */ label?: string | undefined; /** A short quote of what it pointed at when the thread began, shown as text. */ quote?: string | undefined; /** Show the quote in the code font, for a diff line or a value. */ code?: boolean | undefined; /** Whether the thing is still as quoted: "current" (the default), "changed" since, or "gone". The app decides; nothing here checks. */ status?: "current" | "changed" | "gone" | undefined; /** What happened to it, in the app's words, in place of the default line: "The line moved to 48 and was rewritten." */ statusNote?: string | undefined; }- CommentPerson
- A person commenting: a teammate, a reviewer.
{ id: string; name: string; image?: string | undefined }- CommentAuthor
- Who wrote a comment or resolved a thread: a person, or an agent named by the app (never by model output).
{ person: CommentPerson } | { agent: AgentIdentity }- ThreadComment
- One comment, as the app's server has it.
{ id: string; author: CommentAuthor; /** The comment's text. Shown as text, never as markup or a link, whoever wrote it. Ignored once `deletedAt` is set. */ text: string; /** When it was written (ISO). */ at: string; /** When it was last edited (ISO); the comment says "edited". */ editedAt?: string | undefined; /** When it was deleted (ISO). It stays as a line saying so, in its place, with its replies under it. */ deletedAt?: string | undefined; /** The id of the comment it answers. Replies sit under the first comment of their chain. */ replyTo?: string | undefined; /** Whether the viewer hasn't read it, as the app records it. */ unread?: boolean | undefined; /** Whether the viewer wrote it: their own comments can be edited and deleted, and aren't announced as new. */ mine?: boolean | undefined; }- CommentResolution
- Who resolved the thread, and when.
{ by: CommentAuthor; at: string }
Types from other items
The props above take these. Each is defined by another item; import it from the path shown.
- MentionOption from @/lib/candor/chat
{ id: string; /** The heading it sits under in the menu, e.g. "Files", "People". Groups appear in the order first used. */ group: string; entity?: EntityRef | undefined; label: string; description?: string | undefined; /** A short state at the end of its row, such as "Connected"; `tone` colours the dot beside it. */ status?: { label: string; tone?: "success" | "warning" | "neutral" | undefined; } | undefined; /** Why it can't be chosen now, e.g. "The server isn't answering". It stays in the menu, says why, and inserts nothing. */ unavailable?: string | undefined; /** Called in place of inserting it, e.g. to sign in to a source first. The typed @ text is removed and the menu closes. */ onChoose?: (() => void) | undefined; }- ListState from @/lib/candor/data
- What a list or table is showing, the same shape as `ChartState` and as page-state's `ListState` (which the free tier ships, so it declares its own rather than import this file): its rows (ready), the shape of rows still coming (loading), nothing and why (empty), the rows it has with a note on what's missing (partial), or why it couldn't load (error). Lists without a `state` show their rows.
ChartState- HeadingLevel from @/lib/candor/heading-level
- A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6- AgentIdentity from @/lib/candor/chat
{ id: string; /** The displayed name, e.g. "Architect agent". Never derived from model output. */ name: string; role: AgentRole; specialty?: string | undefined; }- EntityRef from @/lib/candor/chat
{ kind: EntityKind; /** The identifier, e.g. "orders.status" or "billing.invoice.due_date". */ name: string; /** A friendlier label when there is one, e.g. "Region". */ label?: string | undefined; /** Where it lives, when more than one place is in scope, e.g. "Shop staging". */ location?: string | undefined; }- ChartState from @/lib/candor/data
{ kind: "ready"; } | { kind: "loading"; } /** Nothing to plot, and why. */ | { kind: "empty"; reason: string; } /** Some of the range is missing. */ | { kind: "partial"; note: string; } | { kind: "error"; reason: string; }- AgentRole from @/lib/candor/chat
- What an agent does: "assistant", "coder", "reviewer"… The app registers each role's glyph and hue (AgentRolesProvider).
string