forked from farhoodlabs/paperclip
2488dc703c
Add project mention system using project:// URI scheme with optional color parameter. Mentions render as colored pill chips in markdown bodies and the WYSIWYG editor. Autocomplete in editors shows both agents and projects. Server extracts mentioned project IDs from issue content and returns them in the issue detail response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import type { IssueComment, Agent } from "@paperclip/shared";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Paperclip } from "lucide-react";
|
|
import { Identity } from "./Identity";
|
|
import { MarkdownBody } from "./MarkdownBody";
|
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
|
import { StatusBadge } from "./StatusBadge";
|
|
import { formatDateTime } from "../lib/utils";
|
|
|
|
interface CommentWithRunMeta extends IssueComment {
|
|
runId?: string | null;
|
|
runAgentId?: string | null;
|
|
}
|
|
|
|
interface LinkedRunItem {
|
|
runId: string;
|
|
status: string;
|
|
agentId: string;
|
|
createdAt: Date | string;
|
|
startedAt: Date | string | null;
|
|
}
|
|
|
|
interface CommentReassignment {
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
}
|
|
|
|
interface ReassignOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface CommentThreadProps {
|
|
comments: CommentWithRunMeta[];
|
|
linkedRuns?: LinkedRunItem[];
|
|
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
|
issueStatus?: string;
|
|
agentMap?: Map<string, Agent>;
|
|
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?: ReassignOption[];
|
|
mentions?: MentionOption[];
|
|
}
|
|
|
|
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
|
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 parseReassignment(target: string): CommentReassignment | null {
|
|
if (!target) return null;
|
|
if (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;
|
|
}
|
|
|
|
type TimelineItem =
|
|
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
|
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
|
|
|
export function CommentThread({
|
|
comments,
|
|
linkedRuns = [],
|
|
onAdd,
|
|
issueStatus,
|
|
agentMap,
|
|
imageUploadHandler,
|
|
onAttachImage,
|
|
draftKey,
|
|
liveRunSlot,
|
|
enableReassign = false,
|
|
reassignOptions = [],
|
|
mentions: providedMentions,
|
|
}: CommentThreadProps) {
|
|
const [body, setBody] = useState("");
|
|
const [reopen, setReopen] = useState(true);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [attaching, setAttaching] = useState(false);
|
|
const [reassign, setReassign] = useState(false);
|
|
const [reassignTarget, setReassignTarget] = useState("");
|
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
|
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
|
|
|
const timeline = useMemo<TimelineItem[]>(() => {
|
|
const commentItems: TimelineItem[] = comments.map((comment) => ({
|
|
kind: "comment",
|
|
id: comment.id,
|
|
createdAtMs: new Date(comment.createdAt).getTime(),
|
|
comment,
|
|
}));
|
|
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
|
|
kind: "run",
|
|
id: run.runId,
|
|
createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(),
|
|
run,
|
|
}));
|
|
return [...commentItems, ...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);
|
|
return a.kind === "comment" ? -1 : 1;
|
|
});
|
|
}, [comments, linkedRuns]);
|
|
|
|
// 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: a.id,
|
|
name: a.name,
|
|
}));
|
|
}, [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 (enableReassign) return;
|
|
setReassign(false);
|
|
setReassignTarget("");
|
|
}, [enableReassign]);
|
|
|
|
async function handleSubmit() {
|
|
const trimmed = body.trim();
|
|
if (!trimmed) return;
|
|
const reassignment = reassign ? parseReassignment(reassignTarget) : null;
|
|
if (reassign && !reassignment) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined);
|
|
setBody("");
|
|
if (draftKey) clearDraft(draftKey);
|
|
setReopen(false);
|
|
setReassign(false);
|
|
setReassignTarget("");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
|
const file = evt.target.files?.[0];
|
|
if (!file || !onAttachImage) return;
|
|
setAttaching(true);
|
|
try {
|
|
await onAttachImage(file);
|
|
} finally {
|
|
setAttaching(false);
|
|
if (attachInputRef.current) attachInputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
const canSubmit = !submitting && !!body.trim() && (!reassign || !!parseReassignment(reassignTarget));
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length})</h3>
|
|
|
|
{timeline.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No comments or runs yet.</p>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{timeline.map((item) => {
|
|
if (item.kind === "run") {
|
|
const run = item.run;
|
|
return (
|
|
<div key={`run:${run.runId}`} className="border border-border bg-accent/20 p-3 overflow-hidden min-w-0 rounded-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Link to={`/agents/${run.agentId}`} className="hover:underline">
|
|
<Identity
|
|
name={agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8)}
|
|
size="sm"
|
|
/>
|
|
</Link>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDateTime(run.startedAt ?? run.createdAt)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<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-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
|
|
>
|
|
{run.runId.slice(0, 8)}
|
|
</Link>
|
|
<StatusBadge status={run.status} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const comment = item.comment;
|
|
return (
|
|
<div key={comment.id} className="border border-border p-3 overflow-hidden min-w-0 rounded-sm">
|
|
<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="text-xs text-muted-foreground">
|
|
{formatDateTime(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
|
{comment.runId && (
|
|
<div className="mt-2 pt-2 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-[10px] 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-[10px] font-mono text-muted-foreground">
|
|
run {comment.runId.slice(0, 8)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{liveRunSlot}
|
|
|
|
<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-[60px] text-sm"
|
|
/>
|
|
<div className="flex items-center justify-end gap-3">
|
|
{(onAttachImage || enableReassign) && (
|
|
<div className="mr-auto flex items-center gap-3">
|
|
{onAttachImage && (
|
|
<>
|
|
<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>
|
|
</>
|
|
)}
|
|
{enableReassign && (
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={reassign}
|
|
onChange={(e) => {
|
|
setReassign(e.target.checked);
|
|
if (!e.target.checked) setReassignTarget("");
|
|
}}
|
|
className="rounded border-border"
|
|
/>
|
|
Reassign
|
|
</label>
|
|
<select
|
|
value={reassignTarget}
|
|
onFocus={() => setReassign(true)}
|
|
onMouseDown={() => setReassign(true)}
|
|
onChange={(event) => {
|
|
setReassign(true);
|
|
setReassignTarget(event.target.value);
|
|
}}
|
|
className="h-8 rounded border border-border bg-background px-2 text-xs"
|
|
>
|
|
<option value="">Select assignee...</option>
|
|
{reassignOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isClosed && (
|
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={reopen}
|
|
onChange={(e) => setReopen(e.target.checked)}
|
|
className="rounded border-border"
|
|
/>
|
|
Re-open
|
|
</label>
|
|
)}
|
|
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
|
{submitting ? "Posting..." : "Comment"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|