# Comments on agent work: anchor a discussion to a run, plan or diff

When an agent proposes a plan, changes code or writes an output, the people checking it need to talk about one part of it: this step, this line, this figure. A chat beside the work loses track of which part it was about, and an approval only records yes or no. A comment thread on the exact thing keeps the question with what it was asked about.

CommentThread takes an anchor: a kind in your app's words ("step", "line", "field", "output"), your own id for it, a label, and a short quote of what it said when the thread began. You store the anchor with the thread. Each time the thread is read, your server checks whether the thing is still as quoted and passes `status`: "current", "changed" or "gone". The thread then says "The line this was on has changed since" or "no longer exists", and the quote shows what was discussed. Candor compares nothing itself, so the check is yours to get right: for a diff line, look for the quoted text near its old position before calling it changed, and say where it went in `statusNote`.

Comments come from people and agents. An agent's comment is written by your server under an `AgentIdentity` from your own roster, so the app names it and the thread marks it as an agent; its text is shown as text, like everyone's. Replies sit under the comment they answer. Resolve and Reopen act on the whole thread and show who did it and when, from what your server returns. Every action calls your server: the thread shows "Sending…", "Saving…", "Deleting…" or "Resolving…" until the data you pass back says it happened, and says so if it failed.

Presence shows who else is looking at the same thing, people and agents, from your presence service: viewing, editing, or idle since a time, and "State unknown" where the service doesn't say. Pass `updatedAt` and it stamps presence that has gone stale, so an old list isn't read as now. Put it in the thread's `presence` slot, or in ThreadHeader's `actions` for the whole run.

Web page: https://candor.themethodic.com/patterns/comments-on-agent-work

## Components

- **Comment thread** (`comment-thread`, Pro; https://candor.themethodic.com/docs/comment-thread.md): The discussion on one anchor: comments, replies, edit and delete, resolve, unread (Pro).
- **Presence** (`presence`, Pro; https://candor.themethodic.com/docs/presence.md): Who is looking at the same thing now, and what they're doing, stamped when stale (Pro).
- **Thread header** (`thread-header`, Pro; https://candor.themethodic.com/docs/thread-header.md): The run's header; Presence goes in its `actions` (Pro).
- **Plan** (`plan`, Pro; https://candor.themethodic.com/docs/plan.md): A plan's steps, whose ids anchor step threads (Pro).
- **Change diff** (`change-diff`, Pro; https://candor.themethodic.com/docs/change-diff.md): The diff the line threads are about, shown beside them (Pro).
- **Side panel** (`side-panel`, Pro; https://candor.themethodic.com/docs/side-panel.md): Where a thread opens next to the work it's about (Pro).

## Install

```bash
npx candor-ui add comment-thread presence
```

## Code

`lib/anchors.ts`: The stored anchor, and the server-side check that says whether the thing has changed or gone.

```tsx
/** What a thread is on. Stored with the thread when its first comment is written. */
export type StoredAnchor = {
  kind: "step" | "line" | "output"
  /** Your own id: a plan step's id, "path@base-commit:line" for a diff line, a run's output id. */
  id: string
  label: string
  /** What it said when the thread began, kept short. */
  quote: string
}

/**
 * Whether the thing is still as quoted, worked out on your server each time the thread is read. Candor shows what
 * you pass; it doesn't compare anything itself.
 */
export function anchorStatus(anchor: StoredAnchor, now: { text: string; line?: number } | undefined) {
  if (!now) return { status: "gone" as const }
  if (now.text.trim() === anchor.quote.trim()) return { status: "current" as const }
  return { status: "changed" as const }
}

/** For a diff line: look for the quoted text near where it was, so a line that only moved isn't called changed. */
export function findLine(lines: string[], anchor: StoredAnchor, was: number) {
  for (let d = 0; d <= 20; d++) {
    for (const at of [was - d, was + d]) {
      if (lines[at - 1]?.trim() === anchor.quote.trim()) return { text: lines[at - 1]!, line: at }
    }
  }
  return lines[was - 1] === undefined ? undefined : { text: lines[was - 1]!, line: was }
}
```

`components/step-comments.tsx`: One thread kept current over server-sent events, each action posting to your own routes.

```tsx
"use client"

import * as React from "react"
import { CommentThread, type CommentAnchor, type CommentResolution, type ThreadComment } from "@/components/candor/comment-thread"
import { Presence, type PresenceEntry } from "@/components/candor/presence"

type ThreadData = { anchor: CommentAnchor; comments: ThreadComment[]; resolved?: CommentResolution; presence: PresenceEntry[] | null; presenceAt?: string }

/** One thread, kept current from your server; every action posts there and returns what the server saved. */
export function StepComments({ threadId }: { threadId: string }) {
  const [data, setData] = React.useState<ThreadData | null>(null)
  const [unreachable, setUnreachable] = React.useState(false)

  React.useEffect(() => {
    // Your own live channel: here, server-sent events that carry the whole thread whenever it changes.
    const events = new EventSource(`/api/threads/${threadId}/events`)
    events.onmessage = (e) => setData(JSON.parse(e.data) as ThreadData)
    events.onerror = () => setUnreachable(true)
    return () => events.close()
  }, [threadId])

  const post = async (path: string, body?: unknown) => {
    const res = await fetch(`/api/threads/${threadId}${path}`, { method: "POST", body: JSON.stringify(body ?? {}) })
    if (!res.ok) throw new Error(await res.text())
    return res.json() as Promise<{ id?: string }>
  }

  return (
    <CommentThread
      anchor={data?.anchor ?? { kind: "step", id: threadId }}
      comments={data?.comments ?? []}
      resolved={data?.resolved}
      state={unreachable ? { kind: "error", reason: "The thread couldn't be reached." } : data ? undefined : { kind: "loading" }}
      onComment={async (text, { replyTo }) => (await post("/comments", { text, replyTo })).id}
      onEdit={(id, text) => post(`/comments/${id}/edit`, { text })}
      onDelete={(id) => post(`/comments/${id}/delete`)}
      onResolve={() => post("/resolve")}
      onReopen={() => post("/reopen")}
      onMarkRead={(ids) => void post("/read", { ids })}
      presence={data && <Presence entries={data.presence} updatedAt={data.presenceAt} />}
    />
  )
}
```

## Notes

- Left to the app: storing threads and comments, who may comment, edit, delete and resolve, the live channel that brings other people's comments in, unread tracking, notifications, and asking an agent to reply. Your server enforces the permissions; the thread only shows the controls you give it handlers for.
- Plan and ChangeDiff don't draw a comment marker on a step or a line. Show the threads beside the work (in a SidePanel tab, or a list under the plan) and use `onShowAnchor` to scroll to the thing a thread is on.
- Decide what a deleted comment leaves and say it with `afterDelete`: a line in its place (the default) or nothing. If your server can restore a deleted comment, say how with `deleteNote`; otherwise the confirmation says it can't be brought back.
- Pass presence only from a service that reports it. Leave out the viewer, pass `null` when the service is down, and pass `updatedAt` whenever the data can lag.
