The Comment row is the unit of conversation on an issue. Same model covers human prose, agent prose, rolling agent-run status updates, and server-authored ambient notices. Kind discriminates them.
prisma
model Comment { id String @id @default(cuid()) workspaceId String issueId String? executionStepId String? authorId String? // null for SYSTEM rows authoringAgentId String? // set when an API key linked to an Agent posted the row body String @db.Text kind CommentKind @default(BODY) // BODY | STATUS | SYSTEM runId String? @unique // STATUS rows pair 1:1 with an AgentRun currentStep String? // STATUS-only step label revisions Json? @default("[]") // see Edit history below editedAt DateTime? // set on the most recent body edit confidence ConfidenceLevel? // LOW | MEDIUM | HIGH; agent-only signal suggestedReplies String[] @default([]) // quick-reply chips createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime?}enum CommentKind { BODY STATUS SYSTEM}enum ConfidenceLevel { LOW MEDIUM HIGH}
STATUS rows are upserted via comment.upsertStatus — one row per AgentRun. They render inside the same chronological issue timeline as BODY and SYSTEM rows, but their effective timestamp is updatedAt so a rolling live-status update moves to the point where the agent actually reported progress. Active / stalled / waiting status rows render with a soft ember "live status" chip; terminal rows render as "run status". The always-current control surface lives in the issue run strip above the thread and near the composer. SYSTEM rows are server-authored narration (assignment notices, dispatch provenance) — no avatar, no card, rendered as a thin separator-style line.
Agents can embed structured tool-call summaries inline by emitting a fenced :::tool block in the comment body. The issue surface lifts each block out of the markdown stream and renders it as a collapsible card with visual parity to the chat surface's tool-call cards.
Wrapping up — I transitioned the issue and closed the loop.:::tool{ "name": "issues.transition", "args": { "issueId": "ckxabcd…", "statusId": "ckxstat…" }, "status": "executed", "summary": "Marked AXI-42 as Done"}:::
Body parsing lives in src/components/issue-detail/comment-tool-call-card.tsx (splitToolDirectives). The directive is parsed at the comment surface, not in the shared markdown renderer — that keeps the cross-cutting attachment-renderer free of comment-specific knobs and avoids double-rendering the JSON as a code block.
Both BODY and STATUS rows accumulate revisions on the revisions JSON column. STATUS upserts push [{ body, currentStep, ts }] (already shipped). BODY edits via comment.update push [{ body, editedAt }]. The column is capped server-side: STATUS keeps the most recent 50, BODY keeps the most recent 20 — oldest entries drop first.
Comment.editedAt mirrors the updatedAt value at the time of the most recent body edit. It's distinct from updatedAt (which Prisma bumps on every column change, including confidence / suggestedReplies), so the UI uses editedAt to decide whether to surface the marker.
On the issue detail page, comments with editedAt != null render a small (edited) link next to the timestamp. Clicking opens a popover that fetches trpc.comment.history({ commentId }) and renders the revision stack newest-first. Each revision shows the prior body (through the full markdown renderer, so attachments and refs still work) plus a relative timestamp. Component: src/components/issue-detail/comment-history-popover.tsx.
The popover degrades gracefully:
If the comment.history route is missing at runtime (e.g. the server hasn't shipped yet), the panel surfaces a one-line "history not available" fallback rather than throwing.
If editedAt is set but the revisions array is empty (e.g. a fresh edit pre-backfill), the panel surfaces a "no prior revisions recorded" note.
Agents can flag their self-assessed confidence in a comment via the confidence column. Only set on AGENT-authored rows — the issue surface explicitly suppresses the chip on human-authored comments even if a stray row carries the field.
Level
Render
Intent
LOW
warm amber chip, low confidence
"Tentative, double-check before acting."
MEDIUM
muted neutral chip, medium
"Ambient context, no strong claim."
HIGH
soft success chip, high
"Verified — fine to proceed without re-deriving."
The chip sits next to the timestamp on both BODY and STATUS rows. Hovering it surfaces a tooltip:
Agent self-reported confidence in this comment. <optional reason>
The optional reason comes from a private one-line field the agent attaches alongside the confidence value (confidenceReason). It only appears in the tooltip — no separate UI surface.
Agents emit confidence via the standard comment.create payload — the field is accepted alongside body and suggestedReplies. Field is optional; omit it when the comment doesn't warrant a signal.
json
{ "issueId": "ckx…", "body": "Transitioned the issue and posted a final summary.", "confidence": "HIGH", "confidenceReason": "Verified by re-reading the spec section the user linked."}
Comments, issue descriptions, artifact bodies, and notes all share the markdown renderer at src/components/markdown/attachment-renderer.tsx. On top of standard Markdown (headings, fenced code, lists, GFM task lists, pipe tables, blockquotes, hr, emphasis) the renderer recognizes the following Forge primitives.
Inline chips that point at a Forge primitive get a compact hover popover after a 350 ms dwell, powered by the shared <HoverPreviewPortal> primitive (src/components/ui/hover-preview-portal.tsx). The popover is mouse-only (skipped on pointer: coarse), portals to document.body, and flips above the chip when there isn't room below. Click semantics are unchanged — hover is purely additive.
Surface
Card shape
KEY-NN
status pill · key · title · priority · project · assignees + agent. (<IssueHoverPreview> → issue.summary)
@token
Agent: avatar · @profileKey · status pill · capability pills (first 3 + +N) · provider · last heartbeat. Falls back to a user card (avatar · @handle · role pill · last-7-day event count) when the token doesn't resolve as an agent. (<AgentHoverPreview> → agent.summary then user.summary)
ProjectChip
icon · key · name · active issue count · member count · owner · target date. (<EntityHoverPreview kind="project"> → project.summary)
InitiativeChip
diamond · name · status pill · project count · active issue count · owner · target date. (<EntityHoverPreview kind="initiative"> → initiative.summary)
CycleChip
repeat · name · status pill · date range · progress bar · done/in-flight/planned counts. (<EntityHoverPreview kind="cycle"> → cycle.summary)
Each summary query runs with staleTime: 60s so re-hovering the same chip in the same session is free.
headers (required, string[]); rows (required, array of arrays; cells can be string/number/boolean/null — nested objects get stringified to keep layout stable).
align is per-column: "left" | "center" | "right" | null. Padded or truncated to headers.length.
caption is optional, renders as an uppercase eyebrow above the table.
pairs is either an object (insertion order preserved) or an explicit array [{ "key": "…", "value": … }] for duplicate keys / controlled order.
Failure mode: invalid JSON, unknown kind, or wrong shape falls back to a fenced code block with a small "invalid :::data block" warning chip and the parse error — the directive never silently disappears.
Renders the linked artifact inline based on its body shape:
Body
Rendered as
Single fenced code block
Code preview with copy button + language label.
Single  image
Inline image.
Anything else
Markdown preview, capped scroll (~ 18rem).
The card header shows title, type, status, and an Open link to the full artifact page. Closer ::: on the next line is optional.
The chip form [label](forge-attachment:<cuid>) (existing) stays as the non-embedded option — use the directive when you want the body inlined, the chip when you just want a click target.
A lone URL on its own line that matches one of the supported providers is promoted to a block-level embed card. URLs mid-prose still render as plain inline links.
Comments
The
Commentrow is the unit of conversation on an issue. Same model covers human prose, agent prose, rolling agent-run status updates, and server-authored ambient notices. Kind discriminates them.STATUSrows are upserted viacomment.upsertStatus— one row perAgentRun. They render inside the same chronological issue timeline as BODY and SYSTEM rows, but their effective timestamp isupdatedAtso a rolling live-status update moves to the point where the agent actually reported progress. Active / stalled / waiting status rows render with a soft ember "live status" chip; terminal rows render as "run status". The always-current control surface lives in the issue run strip above the thread and near the composer.SYSTEMrows are server-authored narration (assignment notices, dispatch provenance) — no avatar, no card, rendered as a thin separator-style line.Tool-call directive (
:::tool) Agents can embed structured tool-call summaries inline by emitting a fenced
:::toolblock in the comment body. The issue surface lifts each block out of the markdown stream and renders it as a collapsible card with visual parity to the chat surface's tool-call cards.Schema
nameissues.transition. Renders monospace at the top of the card.args{})statuspending|approved|declined|executed|errorexecuted.error/declinedrender destructive.summaryresultBehavior
:::toolblocks per comment are supported — each becomes its own card in document order.Implementation
Body parsing lives in
src/components/issue-detail/comment-tool-call-card.tsx(splitToolDirectives). The directive is parsed at the comment surface, not in the shared markdown renderer — that keeps the cross-cutting attachment-renderer free of comment-specific knobs and avoids double-rendering the JSON as a code block.Edit history
Both
BODYandSTATUSrows accumulate revisions on therevisionsJSON column.STATUSupserts push[{ body, currentStep, ts }](already shipped).BODYedits viacomment.updatepush[{ body, editedAt }]. The column is capped server-side: STATUS keeps the most recent 50, BODY keeps the most recent 20 — oldest entries drop first.Comment.editedAtmirrors theupdatedAtvalue at the time of the most recent body edit. It's distinct fromupdatedAt(which Prisma bumps on every column change, includingconfidence/suggestedReplies), so the UI useseditedAtto decide whether to surface the marker.Visibility
On the issue detail page, comments with
editedAt != nullrender a small(edited)link next to the timestamp. Clicking opens a popover that fetchestrpc.comment.history({ commentId })and renders the revision stack newest-first. Each revision shows the prior body (through the full markdown renderer, so attachments and refs still work) plus a relative timestamp. Component:src/components/issue-detail/comment-history-popover.tsx.The popover degrades gracefully:
comment.historyroute is missing at runtime (e.g. the server hasn't shipped yet), the panel surfaces a one-line "history not available" fallback rather than throwing.editedAtis set but therevisionsarray is empty (e.g. a fresh edit pre-backfill), the panel surfaces a "no prior revisions recorded" note.Confidence
Agents can flag their self-assessed confidence in a comment via the
confidencecolumn. Only set on AGENT-authored rows — the issue surface explicitly suppresses the chip on human-authored comments even if a stray row carries the field.LOWlow confidenceMEDIUMmediumHIGHhighThe chip sits next to the timestamp on both
BODYandSTATUSrows. Hovering it surfaces a tooltip:The optional reason comes from a private one-line field the agent attaches alongside the confidence value (
confidenceReason). It only appears in the tooltip — no separate UI surface.Posting
Agents emit confidence via the standard
comment.createpayload — the field is accepted alongsidebodyandsuggestedReplies. Field is optional; omit it when the comment doesn't warrant a signal.Rich body rendering
Comments, issue descriptions, artifact bodies, and notes all share the markdown renderer at
src/components/markdown/attachment-renderer.tsx. On top of standard Markdown (headings, fenced code, lists, GFM task lists, pipe tables, blockquotes, hr, emphasis) the renderer recognizes the following Forge primitives.Forge tokens (inline)
<img>, click to open the lightbox.[label](forge-attachment:<cuid>)[label](forge-link:https://…)KEY-42@profileKeyHover previews
Inline chips that point at a Forge primitive get a compact hover popover after a 350 ms dwell, powered by the shared
<HoverPreviewPortal>primitive (src/components/ui/hover-preview-portal.tsx). The popover is mouse-only (skipped onpointer: coarse), portals todocument.body, and flips above the chip when there isn't room below. Click semantics are unchanged — hover is purely additive.KEY-NN<IssueHoverPreview>→issue.summary)@token@profileKey· status pill · capability pills (first 3 ++N) · provider · last heartbeat. Falls back to a user card (avatar ·@handle· role pill · last-7-day event count) when the token doesn't resolve as an agent. (<AgentHoverPreview>→agent.summarythenuser.summary)ProjectChip<EntityHoverPreview kind="project">→project.summary)InitiativeChip<EntityHoverPreview kind="initiative">→initiative.summary)CycleChip<EntityHoverPreview kind="cycle">→cycle.summary)Each summary query runs with
staleTime: 60sso re-hovering the same chip in the same session is free.:::data— structured payloads Wrap a JSON body in a fenced data directive so the renderer turns it into a proper UI element instead of indented prose. Three kinds:
table,list,kv.:::data tableheaders(required, string[]);rows(required, array of arrays; cells can be string/number/boolean/null — nested objects get stringified to keep layout stable).alignis per-column:"left" | "center" | "right" | null. Padded or truncated toheaders.length.captionis optional, renders as an uppercase eyebrow above the table.:::data listitemsis an array of strings or{ text, badge? }objects.ordered: trueswitches to a numbered list.:::data kvpairsis either an object (insertion order preserved) or an explicit array[{ "key": "…", "value": … }]for duplicate keys / controlled order.Failure mode: invalid JSON, unknown kind, or wrong shape falls back to a fenced code block with a small "invalid :::data block" warning chip and the parse error — the directive never silently disappears.
:::artifact <id>— inline artifact embed Renders the linked artifact inline based on its body shape:
imageThe card header shows title, type, status, and an Open link to the full artifact page. Closer
:::on the next line is optional.The chip form
[label](forge-attachment:<cuid>)(existing) stays as the non-embedded option — use the directive when you want the body inlined, the chip when you just want a click target.URL embeds — auto-recognized providers
A lone URL on its own line that matches one of the supported providers is promoted to a block-level embed card. URLs mid-prose still render as plain inline links.
youtube.com/watch?v=…,youtu.be/…,youtube.com/embed/…,/shorts/…github.com/<owner>/<repo>/(pull|issues)/<n>#N, title, state badge, comment count.loom.com/share/<id>,loom.com/embed/<id>figma.com/(file|design|proto)/<id>/…?embed_host=forgeiframe.Architecture
src/components/embeds/embed-detector.ts.src/server/services/embed-providers.ts(YouTube oEmbed, GitHub REST API, Loom URL transform).embed.fetchtRPC query (src/server/routers/embed.ts).embed:v1:<url>.allow-scripts allow-same-originplus provider-specific extras.allow-top-navigationis never granted.GITHUB_TOKENenv var for authenticated requests; without it the public unauthenticated rate limit applies.Fallback behaviors
i.ytimg.com/<id>/hqdefault.jpgthumbnail + "YouTube" title; play still works.<owner>/<repo>+#N+ placeholder title and no state chip.embed.fetcherrors entirelyRecipe — triage summary
Where this all lives
src/components/issue-detail/issue-main.tsxsrc/components/issue-detail/comment-tool-call-card.tsxsrc/components/issue-detail/comment-history-popover.tsxsrc/components/issue-detail/confidence-chip.tsxsrc/components/markdown/attachment-renderer.tsxsrc/components/data-blocks/src/components/embeds/src/server/routers/embed.ts(usessrc/server/services/embed-providers.ts)src/server/routers/comment.tsprisma/schema.prisma(modelComment, enumConfidenceLevel)