Files
PaperClipAI/ui/src/components/CommentThread.tsx
T
scotttongandClaude Fable 5 c90e66bdd6 feat(ui): design-system component convergence — Card/Badge adoption, multiplicative radius ladder, unified list surfaces (#9240)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is governed by a design system (`DESIGN.md` + the token layer
in `ui/src/index.css`, merged in #9134) whose first principle is "one
way to say each thing" — one Card, one Badge, one nav row
> - After the token extraction landed, ~35 files still hand-rolled card
containers, ~45 files hand-rolled pill spans, the sidebar agents section
duplicated the nav-row chrome, and the inbox and tasks lists rendered
the same task rows two subtly different ways
> - Each divergence is a place where a future design change (radius,
hover language, status vocabulary) silently misses surfaces, defeating
the "edit tokens + run checks" model the design system exists for
> - This pull request converges those surfaces onto the shared
primitives, codifies the radius scale as the modern multiplicative
shadcn ladder, and unifies the row/hover/tree-guide language across the
inbox and tasks lists — every visible delta was human-reviewed
screen-by-screen against a live instance across nine feedback rounds
> - The benefit is that the app's look is now steerable from single
knobs (one `--radius` anchor, one Card, one Badge, one row component),
and the visual regression suite covers the result (514 snapshots
including a new AgentDetail page story)

## Linked Issues or Issue Description

No public GitHub issue exists for this work; describing per the feature
template:

- **Problem**: after the design-token foundation (#9134),
component-level drift remained — hand-rolled cards/pills, duplicated
sidebar row chrome, and two different renderings of task rows (inbox vs
tasks list) meant design changes had to be applied per-surface and
frequently missed spots (e.g. status glyphs rendered 16px in the inbox
but 20px in the tasks list because a slot override silently beat the
component default).
- **Proposed behavior**: all card-shaped containers render via `Card`,
all label pills via `Badge`, sidebar rows via `SidebarNavItem`, and both
task-list surfaces via one `IssueRow` configuration; the radius scale is
a single multiplicative ladder anchored at `--radius: 0.5rem`.
- **Alternatives considered**: converting interactive
`<button>`/`<Link>` cards to `Card` divs (rejected — breaks semantics;
documented inline with `design-allow` comments instead); keeping the
legacy additive radius ladder (rejected in favor of the standard shadcn
multiplicative mapping).

## What Changed

- `Card` adoption across ~35 files (settings pages, auth/board flows,
dashboards, list containers, KPI tiles); non-adoptable sites
(interactive cards, `<li>` rows, class-string props, chart tooltip)
carry documented `design-allow(card-pattern)` comments
- `Card` gains an `interactive` prop — one quiet hover affordance for
clickable cards (cursor, border darken, shadow lift, focus ring),
applied to skills tiles, artifact cards, and the company selector; cards
carry no resting shadow
- `Badge` adoption for 113 hand-rolled pill spans across ~45 files;
`PropertyChip` wraps `Badge` internally; `StatusBadge`, external-object
chips, and match chips stay bespoke by documented decision (WCAG-tuned
status mechanics)
- Radius ladder becomes the multiplicative shadcn mapping
(`sm/md/lg/xl/2xl/3xl/4xl = 0.6/0.8/1.0/1.4/1.8/2.2/2.6 × --radius`,
anchor `0.5rem`); every card surface unifies on `rounded-lg`; the
orphaned 8px literal token is deleted
- Sidebar: agent rows render via `SidebarNavItem` (new additive props:
`iconNode`, `active`, `trailing`, `liveAccessory`); live dots use
`--status-agent-running`; one row rhythm and inset rounded pill
highlight; right-aligned trailing badges; every labeled section is
collapsible
- Inbox + tasks lists unified: md status glyphs, `accent/50` rounded row
hovers, vertical tree guides under parent rows (opaque underlay so
dark-mode translucent borders don't stack), no horizontal dividers under
expanded parents; the swipe-to-archive reveal layer shows only
mid-swipe; board toggle uses the `SquareKanban` glyph
- Kanban: every column carries a status-hued tint; lanes default
expanded (including empty); compact mode collapses empty lanes to
labeled rails (fixes a clipped, label-less empty-column state)
- Storybook: new AgentDetail page story (realistic fixtures, light+dark)
joins the visual suite; suite captures with `reducedMotion: 'reduce'`
and the ux-lab reasoning ticker honors `prefers-reduced-motion`; a stale
lexical alias in `storybook/main.ts` is fixed (build was broken since
the lexical 0.46 bump)
- Keyboard navigation, from live review of the unified lists: inbox
navigation keys work on every tab (archive keys stay scoped to the
archivable tab); keyboard-driven scrolling no longer hands the selection
to whatever row lands under the stationary cursor (hover selects only
after real pointer movement); the tasks list view gains the same j/k /
arrows / Enter selection model as the inbox; and the `g` then `i`
go-to-inbox chord works app-wide instead of only on the issue detail
page
- Token gates restored to 3/3 CLEAN (tokenized a post-#9134 regression
in the recovery card); decisions recorded in
`doc/design/DECISION-SHEET.md` and `doc/design/COMPONENT-INVENTORY.md`
(investigation verdicts: FileTree vs WorkspaceFileBrowser and the four
entity pickers stay separate — evidence included)

## Verification

- `pnpm check:token-gates` → 3/3 CLEAN
- `pnpm typecheck` → green (all packages)
- `cd ui && npx vitest run` → 2106/2106 (assertions updated in lockstep
where they documented superseded decisions; new tests for the global
go-to-inbox chord)
- `pnpm --filter @paperclipai/ui build` → exit 0
- `pnpm build-storybook` → succeeds (also fixes the lexical-alias break
on master)
- Visual regression: 514-snapshot Playwright suite green against the
updated baseline (zero diffs from the keyboard-navigation round — those
changes are purely behavioral). Note: baselines live outside git per the
suite design and the baseline-manifest archive is not yet published, so
CI cannot run this suite — it was run locally throughout; every visible
delta was reviewed screen-by-screen in a live instance across nine
review rounds. Review evidence (before/after triplets) intentionally
kept out of the repo for size; available on request.
- Manual: exercised dashboard, tasks (list + board), inbox, agents,
skills, costs, settings, and artifact surfaces in light and dark themes

## Risks

- Wide but shallow visual surface: most changes are class-string
substitutions with behavior preserved (props, handlers, roles, test
ids). The riskiest areas — dnd-kit card refs (React 19 ref-as-prop),
inbox swipe-to-archive, and sidebar overlays — are covered by existing
unit tests (all green) and were manually exercised.
- Intentional visual deltas (rounded cards, tinted kanban columns, md
status glyphs, unified hovers) are design decisions recorded in
`doc/design/DECISION-SHEET.md`; each maps to a re-baselined snapshot set
locally.
- The visual suite cannot yet run in CI (unpublished baseline archive —
pre-existing condition from #9134); until that lands, snapshot coverage
is local-only.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic
coding with tool use (file editing, test execution, Playwright
screenshot verification); extended thinking enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (will confirm once CI runs)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending first review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:15:45 -07:00

1109 lines
39 KiB
TypeScript

import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link, useLocation } from "react-router-dom";
import type {
Agent,
Approval,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
runId?: string | null;
runAgentId?: string | null;
clientId?: string;
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
followUpRequested?: boolean;
}
interface LinkedRunItem {
runId: string;
status: string;
agentId: string;
createdAt: Date | string;
startedAt: Date | string | null;
environment?: {
id: string;
name: string;
driver: string;
} | null;
environmentLease?: {
id: string;
status: string;
leasePolicy: string;
provider: string | null;
providerLeaseId: string | null;
executionWorkspaceId: string | null;
workspacePath: string | null;
failureReason: string | null;
cleanupStatus: string | null;
} | null;
finishedAt?: Date | string | null;
}
interface CommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
linkedApprovals?: Approval[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
timelineEvents?: IssueTimelineEvent[];
companyId?: string | null;
projectId?: string | null;
onApproveApproval?: (approvalId: string) => Promise<void>;
onRejectApproval?: (approvalId: string) => Promise<void>;
pendingApprovalAction?: {
approvalId: string;
action: "approve" | "reject";
} | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
imageUploadHandler?: (file: File) => Promise<string>;
/** Callback to attach an image file to the parent issue (not inline in a comment). */
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
liveRunSlot?: React.ReactNode;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
composerDisabledReason?: string | null;
externalReferences?: MarkdownExternalReferenceMap;
}
const DRAFT_DEBOUNCE_MS = 800;
function loadDraft(draftKey: string): string {
try {
return localStorage.getItem(draftKey) ?? "";
} catch {
return "";
}
}
function saveDraft(draftKey: string, value: string) {
try {
if (value.trim()) {
localStorage.setItem(draftKey, value);
} else {
localStorage.removeItem(draftKey);
}
} catch {
// Ignore localStorage failures.
}
}
function clearDraft(draftKey: string) {
try {
localStorage.removeItem(draftKey);
} catch {
// Ignore localStorage failures.
}
}
function BreakablePath({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
const segments = text.split(/(?<=[\/-])/);
for (let i = 0; i < segments.length; i++) {
if (i > 0) parts.push(<wbr key={i} />);
parts.push(segments[i]);
}
return <>{parts}</>;
}
function parseReassignment(target: string): CommentReassignment | null {
if (!target || target === "__none__") {
return { assigneeAgentId: null, assigneeUserId: null };
}
if (target.startsWith("agent:")) {
const assigneeAgentId = target.slice("agent:".length);
return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null;
}
if (target.startsWith("user:")) {
const assigneeUserId = target.slice("user:".length);
return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null;
}
return null;
}
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked";
return resumesToTodo && assigneeValue.startsWith("agent:");
}
function humanizeValue(value: string | null): string {
if (!value) return "None";
return value.replace(/_/g, " ");
}
function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (assignee.agentId) {
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
}
if (assignee.userId) {
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
}
return "Unassigned";
}
function formatTimelineActorName(
actorType: IssueTimelineEvent["actorType"],
actorId: string,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (actorType === "agent") {
return agentMap?.get(actorId)?.name ?? actorId.slice(0, 8);
}
if (actorType === "system") {
return "System";
}
return formatAssigneeUserLabel(actorId, currentUserId) ?? "Board";
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
return "timed out";
default:
return status.replace(/_/g, " ");
}
}
function runTimestamp(run: LinkedRunItem) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
function runStatusClass(status: string) {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-blue-700 dark:text-blue-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
async function copyTextWithFallback(text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
try {
textarea.select();
const success = document.execCommand("copy");
if (!success) throw new Error("execCommand copy failed");
} finally {
document.body.removeChild(textarea);
}
}
function CopyMarkdownButton({ text }: { text: string }) {
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy";
return (
<button
type="button"
className={cn(
"inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
status === "copied"
? "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-300"
: status === "failed"
? "bg-destructive/10 text-destructive"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
title={label}
aria-label="Copy comment as markdown"
onClick={() => {
void copyTextWithFallback(text)
.then(() => setStatus("copied"))
.catch(() => setStatus("failed"));
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setStatus("idle");
timeoutRef.current = null;
}, 1500);
}}
>
{status === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sm:hidden">{label}</span>
<span className="sr-only" aria-live="polite">
{label}
</span>
</button>
);
}
function CommentCard({
comment,
agentMap,
companyId,
projectId,
feedbackVote = null,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
voting = false,
highlightCommentId,
queued = false,
externalReferences,
}: {
comment: CommentWithRunMeta;
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVote?: FeedbackVoteValue | null;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
voting?: boolean;
highlightCommentId?: string | null;
queued?: boolean;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
const isDeleted = Boolean(comment.deletedAt);
const followUpRequested = comment.followUpRequested === true;
return (
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
isQueued
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
: isHighlighted && !isDeleted
? "border-primary/50 bg-primary/5"
: "border-border"
} ${isPending ? "opacity-80" : ""} ${isDeleted ? "bg-muted/30 text-muted-foreground" : ""}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{isQueued ? (
<Badge variant="outline" className="border-amber-400/60 bg-amber-100/70 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</Badge>
) : null}
{followUpRequested ? (
<Badge variant="outline" className="text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)">
Follow-up
</Badge>
) : null}
{companyId && !isPending && !isDeleted ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
{isPending ? (
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
) : (
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
)}
{!isDeleted ? <CopyMarkdownButton text={comment.body} /> : null}
</span>
</div>
{isDeleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<MarkdownBody className="text-sm" softBreaks externalReferences={externalReferences}>{comment.body}</MarkdownBody>
)}
{companyId && !isPending && !isDeleted ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.authorAgentId && onVote && !isQueued && !isPending && !isDeleted ? (
<OutputFeedbackButtons
activeVote={feedbackVote}
disabled={voting}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={onVote}
rightSlot={comment.runId && !isPending ? (
comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-(length:--text-nano) font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-(length:--text-nano) font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)
) : undefined}
/>
) : null}
{comment.runId && !isPending && !isDeleted && !(comment.authorAgentId && onVote && !isQueued) ? (
<div className="mt-3 pt-3 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-(length:--text-nano) font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-(length:--text-nano) font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
) : null}
</div>
);
}
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "approval"; id: string; createdAtMs: number; approval: Approval }
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
function TimelineEventCard({
event,
agentMap,
currentUserId,
}: {
event: IssueTimelineEvent;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}) {
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
const actionLabel = event.followUpRequested ? "requested follow-up" : "updated this task";
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">{actionLabel}</span>
<a
href={`#activity-${event.id}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(event.createdAt)}
</a>
</div>
{event.statusChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Status
</span>
<span className="text-muted-foreground">
{humanizeValue(event.statusChange.from)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{humanizeValue(event.statusChange.to)}
</span>
</div>
) : null}
{event.assigneeChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.from, agentMap, currentUserId)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.to, agentMap, currentUserId)}
</span>
</div>
) : null}
{event.workspaceChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Workspace
</span>
<span className="text-muted-foreground">
{formatTimelineWorkspaceLabel(event.workspaceChange.from)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineWorkspaceLabel(event.workspaceChange.to)}
</span>
</div>
) : null}
</div>
</div>
);
}
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
currentUserId,
companyId,
projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction,
feedbackVoteByTargetId,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
votingTargetId,
highlightCommentId,
externalReferences,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
companyId?: string | null;
projectId?: string | null;
onApproveApproval?: (approvalId: string) => Promise<void>;
onRejectApproval?: (approvalId: string) => Promise<void>;
pendingApprovalAction?: {
approvalId: string;
action: "approve" | "reject";
} | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
votingTargetId?: string | null;
highlightCommentId?: string | null;
externalReferences?: MarkdownExternalReferenceMap;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
}
return (
<div className="space-y-3">
{timeline.map((item) => {
if (item.kind === "event") {
return (
<TimelineEventCard
key={`event:${item.event.id}`}
event={item.event}
agentMap={agentMap}
currentUserId={currentUserId}
/>
);
}
if (item.kind === "approval") {
const approval = item.approval;
const isPending = pendingApprovalAction?.approvalId === approval.id;
return (
<div id={`approval-${approval.id}`} key={`approval:${approval.id}`} className="py-1.5">
<ApprovalCard
approval={approval}
requesterAgent={approval.requestedByAgentId ? agentMap?.get(approval.requestedByAgentId) ?? null : null}
onApprove={onApproveApproval ? () => void onApproveApproval(approval.id) : undefined}
onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined}
detailLink={`/approvals/${approval.id}`}
isPending={isPending}
pendingAction={isPending ? pendingApprovalAction?.action ?? null : null}
/>
</div>
);
}
if (item.kind === "run") {
const run = item.run;
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
return (
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
<Avatar size="sm">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
<Link to={`/agents/${run.agentId}`} className="font-medium text-foreground transition-colors hover:underline">
{actorName}
</Link>
<span className="text-muted-foreground">run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
{run.runId.slice(0, 8)}
</Link>
<span className={cn("font-medium", runStatusClass(run.status))}>
{formatRunStatusLabel(run.status)}
</span>
<a
href={`#run-${run.runId}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(runTimestamp(run))}
</a>
</div>
</div>
{run.environment || run.environmentLease ? (
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-(length:--text-micro) text-muted-foreground">
{run.environment ? (
<span>
Environment <span className="text-foreground">{run.environment.name}</span>
<span> · {run.environment.driver}</span>
</span>
) : null}
{run.environmentLease?.provider ? (
<span>
Provider <span className="text-foreground">{run.environmentLease.provider}</span>
</span>
) : null}
{run.environmentLease ? (
<span>
Lease{" "}
<span className="font-mono text-foreground">
{run.environmentLease.id.slice(0, 8)}
</span>
<span> · {run.environmentLease.status}</span>
</span>
) : null}
{run.environmentLease?.workspacePath ? (
<span className="min-w-0 font-mono" style={{ overflowWrap: "anywhere" }}>
<BreakablePath text={run.environmentLease.workspacePath} />
</span>
) : null}
{run.environmentLease?.failureReason ? (
<span className="text-destructive">
Failure: {run.environmentLease.failureReason}
</span>
) : null}
</div>
) : null}
</div>
);
}
const comment = item.comment;
return (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVote={feedbackVoteByTargetId?.get(comment.id) ?? null}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
voting={votingTargetId === comment.id}
highlightCommentId={highlightCommentId}
externalReferences={externalReferences}
/>
);
})}
</div>
);
});
export function CommentThread({
comments,
queuedComments = [],
linkedApprovals = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
timelineEvents = [],
companyId,
projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction = null,
onVote,
onAdd,
issueStatus,
agentMap,
currentUserId,
imageUploadHandler,
onAttachImage,
draftKey,
liveRunSlot,
enableReassign = false,
reassignOptions = [],
currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions,
onInterruptQueued,
interruptingQueuedRunId = null,
composerDisabledReason = null,
externalReferences,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation();
const hasScrolledRef = useRef(false);
const timeline = useMemo<TimelineItem[]>(() => {
const followUpCommentIds = new Set(
timelineEvents
.filter((event) => event.followUpRequested && event.commentId)
.map((event) => event.commentId as string),
);
const commentItems: TimelineItem[] = comments.map((comment) => {
const followUpRequested = comment.followUpRequested === true || followUpCommentIds.has(comment.id);
return {
kind: "comment",
id: comment.id,
createdAtMs: new Date(comment.createdAt).getTime(),
comment: followUpRequested ? { ...comment, followUpRequested } : comment,
};
});
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
kind: "approval",
id: approval.id,
createdAtMs: new Date(approval.createdAt).getTime(),
approval,
}));
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
kind: "event",
id: event.id,
createdAtMs: new Date(event.createdAt).getTime(),
event,
}));
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
kind: "run",
id: run.runId,
createdAtMs: new Date(runTimestamp(run)).getTime(),
run,
}));
return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.kind === b.kind) return a.id.localeCompare(b.id);
const kindOrder = {
event: 0,
approval: 1,
comment: 2,
run: 3,
} as const;
return kindOrder[a.kind] - kindOrder[b.kind];
});
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// Build mention options from agent map (exclude terminated agents)
const mentions = useMemo<MentionOption[]>(() => {
if (providedMentions) return providedMentions;
if (!agentMap) return [];
return Array.from(agentMap.values())
.filter((a) => a.status !== "terminated")
.map((a) => ({
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
}));
}, [agentMap, providedMentions]);
useEffect(() => {
if (!draftKey) return;
setBody(loadDraft(draftKey));
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
saveDraft(draftKey, body);
}, DRAFT_DEBOUNCE_MS);
}, [body, draftKey]);
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
useEffect(() => {
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
const hash = location.hash;
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
const commentId = hash.slice("#comment-".length);
const targetComment = [...comments, ...queuedComments].find((comment) => comment.id === commentId);
if (targetComment?.deletedAt) {
setHighlightCommentId(null);
hasScrolledRef.current = false;
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `${location.pathname}${location.search}`);
}
return;
}
// Only scroll once per hash
if (hasScrolledRef.current) return;
const el = document.getElementById(`comment-${commentId}`);
if (el) {
hasScrolledRef.current = true;
setHighlightCommentId(commentId);
el.scrollIntoView({ behavior: "smooth", block: "center" });
// Clear highlight after animation
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
return () => clearTimeout(timer);
}
}, [location.hash, comments, queuedComments]);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const reopen = shouldImplicitlyReopenComment(
issueStatus,
hasReassignment ? reassignTarget : currentAssigneeValue,
) ? true : undefined;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
await onAdd(submittedBody, reopen, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
// Parent mutation handlers surface the failure and the draft is restored for retry.
} finally {
setSubmitting(false);
}
}
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
setAttaching(true);
try {
if (imageUploadHandler) {
const url = await imageUploadHandler(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
async function handleFeedbackVote(
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) {
if (!onVote) return;
setVotingTargetId(commentId);
try {
await onVote(commentId, vote, options);
} finally {
setVotingTargetId(null);
}
}
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
<TimelineList
timeline={timeline}
agentMap={agentMap}
currentUserId={currentUserId}
companyId={companyId}
projectId={projectId}
onApproveApproval={onApproveApproval}
onRejectApproval={onRejectApproval}
pendingApprovalAction={pendingApprovalAction}
feedbackVoteByTargetId={feedbackVoteByTargetId}
feedbackDataSharingPreference={feedbackDataSharingPreference}
onVote={onVote ? handleFeedbackVote : undefined}
votingTargetId={votingTargetId}
highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl}
externalReferences={externalReferences}
/>
{liveRunSlot}
{queuedComments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-(--tracking-eyebrow) text-amber-700 dark:text-amber-300">
Queued Comments ({queuedComments.length})
</h4>
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
>
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
</div>
<div className="space-y-3">
{queuedComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
externalReferences={externalReferences}
/>
))}
</div>
</div>
)}
{composerDisabledReason ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
{composerDisabledReason}
</div>
) : (
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-(--sz-60px) text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Responsible</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div>
</div>
)}
</div>
);
}