mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks let agents ask a person for input. > - The reply composer needs a useful default recipient. > - A recent comment does not identify the agent that assigned the task. > - The old fallback can select the person who is writing the reply. > - This change uses the latest agent assignment and keeps manual choices. ## Linked Issues or Issue Description **What happened?** When an agent assigns a task to a person, the reply composer can select that person or a different commenter. A data refresh can also replace a manual selection. **Expected behavior** For a task assigned to the current person, select the latest agent that assigned it to them. If no such assignment exists, use the latest other commenter. If neither exists, leave the recipient empty. Keep explicit choices during refreshes. **Steps to reproduce** 1. Have agent Alpha assign a task to a person. 2. Assign it to agent Beta, then have Beta assign it to the same person. 3. Open the task as that person. 4. Check that the reply composer selects Beta. 5. Select another recipient. Refresh task data. Check that the selection stays. Related work: #15091 shares task composer controls. This change fixes recipient defaults. ## What Changed - Read real assignment changes from REST and native runner task activity, in time order. - Record human assignee fields in native create and reassign activity. - Ignore unchanged assignment fields and assignments to other people. - Use the assigning agent before comment hints for human-owned tasks. - Connect the suggestion to current and classic task composers and pipeline replies. - Keep manual selections when suggestions change. - Add regression tests and document the fallback. - Use tasks with no assignee in the attachment receipt tests. This keeps the tests on the comment route. Dismiss announcements through the real UI. - Record human and agent assignees in REST creation receipts. Include child creation in the assignment history resolver. ## Verification - Focused tests cover assignment order, creation, other recipients, unchanged fields, fallback, and manual selections. - Browser fixture uses the real composer and assignment resolver. It selects Beta and keeps a manual human selection after history refresh. - Passed: 223 focused tests across four suites. - Passed: `pnpm --filter @paperclipai/ui typecheck`. - Passed: `pnpm --filter @paperclipai/ui build`. - Passed: `pnpm check:token-gates`. - The initial CI run passed full build, typecheck, Rust, unit tests and seven E2E shards. The attachment shard exposed tests that relied on the old self-selection default. The tests now select the recipient explicitly. - Native assignment regression tests and resolver tests pass: 40 tests. - Attachment E2E suite: 10 passed; one page-load timing failure passed on an isolated rerun. All 11 cases passed across the suite and rerun. - Previous full CI passed atcc7ed05. New CI must pass on the current head. - Latest local checks: 16 resolver tests and 7 workspace policy tests pass. REST creation audit tests cover both creation routes. - The latest browser check could not start. The UI build exceeded the local memory limit (exit 137). Full typecheck, tests, build, and browser checks will run in CI. ## Risks - Old activity rows without previous assignment values cannot prove a reassignment. They use the comment fallback. - An empty fallback requires the person to choose a recipient or use the existing no-assignee flow. - No database or API schema changes. ## Model Used OpenAI Codex, GPT-6. The session does not expose an exact model build or context window. Used code editing, shell tools, tests, and browser verification. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Final validation atf83f2f6: 55 checks passed. Two optional Storybook checks were skipped. All browser shards passed. Both review threads are resolved. Classic attachment tests use the real no-assignee confirmation.
1105 lines
40 KiB
TypeScript
1105 lines
40 KiB
TypeScript
import { AgentIdentity } from "./AgentIdentity";
|
|
import { AgentAvatar } from "@/components/AgentAvatar";
|
|
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 { 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 { copyTextToClipboard } from "../lib/clipboard";
|
|
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 | null) => 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";
|
|
}
|
|
}
|
|
|
|
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 copyTextToClipboard(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">
|
|
<AgentIdentity
|
|
agent={agentMap?.get(comment.authorAgentId) ?? { id: 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">
|
|
{event.actorType === "agent" ? <AgentAvatar agent={agentMap?.get(event.actorId) ?? { id: event.actorId, name: actorName }} size={32} className="mt-0.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">
|
|
<AgentAvatar agent={agentMap?.get(run.agentId) ?? { id: run.agentId, name: actorName }} size={32} />
|
|
|
|
<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>
|
|
{/* The raw "sandbox" driver key stays off run details — the
|
|
environment's name and the Provider entry below already
|
|
identify it; other drivers (ssh, local) remain useful. */}
|
|
{run.environment.driver !== "sandbox" ? (
|
|
<span> · {run.environment.driver}</span>
|
|
) : null}
|
|
</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 manualAssigneeRef = useRef(false);
|
|
const assigneeDraftKeyRef = useRef(draftKey);
|
|
const selectAssignee = (value: string) => {
|
|
manualAssigneeRef.current = true;
|
|
setReassignTarget(value);
|
|
};
|
|
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,
|
|
agentAppearance: a.appearance,
|
|
}));
|
|
}, [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(() => {
|
|
if (assigneeDraftKeyRef.current !== draftKey) {
|
|
assigneeDraftKeyRef.current = draftKey;
|
|
manualAssigneeRef.current = false;
|
|
}
|
|
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
|
}, [draftKey, 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);
|
|
manualAssigneeRef.current = false;
|
|
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 = ``;
|
|
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={selectAssignee}
|
|
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 ? (
|
|
<AgentAvatar agent={agent} size={16} 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 ? (
|
|
<AgentAvatar agent={agent} size={16} 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>
|
|
);
|
|
}
|