diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b90f1bb8ac..948bdcfd6f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -759,6 +759,7 @@ export type { IssueComment, IssueCommentDerivedAuthorSource, IssueCommentMetadata, + IssueCommentReplyToMetadata, IssueCommentMetadataSection, IssueCommentMetadataRow, IssueCommentMetadataTextRow, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e117d04b5bf..23703bf2b3d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -387,6 +387,7 @@ export type { IssueComment, IssueCommentDerivedAuthorSource, IssueCommentMetadata, + IssueCommentReplyToMetadata, IssueCommentMetadataSection, IssueCommentMetadataRow, IssueCommentMetadataTextRow, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 73e6798b890..751d91d5c1d 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -284,13 +284,20 @@ export const issuesApi = { allowSharing?: boolean; }, ) => api.post(`/issues/${id}/feedback-votes`, data), - addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) => + addComment: ( + id: string, + body: string, + reopen?: boolean, + interrupt?: boolean, + replyToCommentId?: string | null, + ) => api.post( `/issues/${id}/comments`, { body, ...(reopen === undefined ? {} : { reopen }), ...(interrupt === undefined ? {} : { interrupt }), + ...(replyToCommentId ? { metadata: { replyTo: { commentId: replyToCommentId } } } : {}), }, ), cancelComment: (id: string, commentId: string) => diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 2eda8b86947..917045c0a73 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, @@ -7,10 +7,12 @@ import type { FeedbackVote, FeedbackVoteValue, IssueComment, + IssueCommentReplyToMetadata, } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ArrowRight, Check, Copy, Paperclip } from "lucide-react"; +import { ArrowRight, Check, Copy, Paperclip, Reply, X } from "lucide-react"; +import { buildReplyExcerpt, formatReplyAuthorName } from "../lib/comment-reply"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; @@ -88,7 +90,12 @@ interface CommentThreadProps { vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; + onAdd: ( + body: string, + reopen?: boolean, + reassignment?: CommentReassignment, + replyToCommentId?: string | null, + ) => Promise; issueStatus?: string; agentMap?: Map; currentUserId?: string | null; @@ -138,6 +145,50 @@ function clearDraft(draftKey: string) { } } +/** + * The active reply target held by the composer. Self-contained (author + excerpt) so the chip + * renders without re-resolving the original comment, and survives a reload via localStorage. + */ +interface ReplyDraftTarget { + commentId: string; + authorName: string; + excerpt: string; + excerptTruncated: boolean; +} + +function replyDraftKey(draftKey: string) { + return `${draftKey}:reply`; +} + +function loadReplyDraft(draftKey: string): ReplyDraftTarget | null { + try { + const raw = localStorage.getItem(replyDraftKey(draftKey)); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.commentId !== "string" || typeof parsed?.excerpt !== "string") return null; + return { + commentId: parsed.commentId, + authorName: typeof parsed.authorName === "string" ? parsed.authorName : "comment", + excerpt: parsed.excerpt, + excerptTruncated: parsed.excerptTruncated === true, + }; + } catch { + return null; + } +} + +function saveReplyDraft(draftKey: string, target: ReplyDraftTarget | null) { + try { + if (target) { + localStorage.setItem(replyDraftKey(draftKey), JSON.stringify(target)); + } else { + localStorage.removeItem(replyDraftKey(draftKey)); + } + } catch { + // Ignore localStorage failures. + } +} + function BreakablePath({ text }: { text: string }) { const parts: React.ReactNode[] = []; const segments = text.split(/(?<=[\/-])/); @@ -313,6 +364,73 @@ function CopyMarkdownButton({ text }: { text: string }) { ); } +function ReplyButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +/** Compact quoted header rendered above a sent reply's body (accent bar + author + excerpt). */ +function ReplyQuote({ + replyTo, + agentMap, + targetDeleted, + onClick, +}: { + replyTo: IssueCommentReplyToMetadata; + agentMap?: Map; + targetDeleted: boolean; + onClick?: () => void; +}) { + const authorName = formatReplyAuthorName(replyTo, agentMap); + const body = ( + + + + {targetDeleted ? ( + Original comment deleted + ) : ( + <> + {authorName}{" "} + + {replyTo.excerpt} + {replyTo.excerptTruncated ? "…" : ""} + + + )} + + + ); + + if (targetDeleted || !onClick) { + return
{body}
; + } + + return ( + + ); +} + function CommentCard({ comment, agentMap, @@ -326,6 +444,9 @@ function CommentCard({ highlightCommentId, queued = false, externalReferences, + onReply, + onScrollToComment, + replyTargetDeleted = false, }: { comment: CommentWithRunMeta; agentMap?: Map; @@ -342,18 +463,24 @@ function CommentCard({ highlightCommentId?: string | null; queued?: boolean; externalReferences?: MarkdownExternalReferenceMap; + onReply?: (comment: CommentWithRunMeta) => void; + onScrollToComment?: (commentId: string) => void; + replyTargetDeleted?: boolean; }) { 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; + const isOptimistic = comment.id.startsWith("optimistic-"); + const replyTo = comment.metadata?.replyTo ?? null; + const canReply = Boolean(onReply) && !isDeleted && !isPending && !isQueued && !isOptimistic; return (
)} + {canReply ? onReply?.(comment)} /> : null} {!isDeleted ? : null}
{isDeleted ? (
Comment deleted
) : ( - {comment.body} + <> + {replyTo ? ( + onScrollToComment(replyTo.commentId) + : undefined + } + /> + ) : null} + {comment.body} + )} {companyId && !isPending && !isDeleted ? (
@@ -579,6 +721,9 @@ const TimelineList = memo(function TimelineList({ votingTargetId, highlightCommentId, externalReferences, + onReply, + onScrollToComment, + deletedCommentIds, }: { timeline: TimelineItem[]; agentMap?: Map; @@ -602,6 +747,9 @@ const TimelineList = memo(function TimelineList({ votingTargetId?: string | null; highlightCommentId?: string | null; externalReferences?: MarkdownExternalReferenceMap; + onReply?: (comment: CommentWithRunMeta) => void; + onScrollToComment?: (commentId: string) => void; + deletedCommentIds?: Set; }) { if (timeline.length === 0) { return

No timeline entries yet.

; @@ -724,6 +872,13 @@ const TimelineList = memo(function TimelineList({ voting={votingTargetId === comment.id} highlightCommentId={highlightCommentId} externalReferences={externalReferences} + onReply={onReply} + onScrollToComment={onScrollToComment} + replyTargetDeleted={ + comment.metadata?.replyTo + ? deletedCommentIds?.has(comment.metadata.replyTo.commentId) ?? false + : false + } /> ); })} @@ -771,6 +926,7 @@ export function CommentThread({ const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); + const [replyingTo, setReplyingTo] = useState(null); const editorRef = useRef(null); const attachInputRef = useRef(null); const draftTimer = useRef | null>(null); @@ -847,9 +1003,42 @@ export function CommentThread({ })); }, [agentMap, providedMentions]); + // Soft-deleted comment ids, so a sent reply whose target was deleted shows a tombstone. + const deletedCommentIds = useMemo(() => { + const ids = new Set(); + for (const comment of [...comments, ...queuedComments]) { + if (comment.deletedAt) ids.add(comment.id); + } + return ids; + }, [comments, queuedComments]); + + const scrollToComment = useCallback((commentId: string) => { + if (typeof document === "undefined") return; + const el = document.getElementById(`comment-${commentId}`); + if (!el) return; + setHighlightCommentId(commentId); + el.scrollIntoView({ behavior: "smooth", block: "center" }); + window.setTimeout(() => { + setHighlightCommentId((current) => (current === commentId ? null : current)); + }, 3000); + }, []); + + const handleReply = useCallback( + (comment: CommentWithRunMeta) => { + const { excerpt, truncated } = buildReplyExcerpt(comment.body); + const authorName = comment.authorAgentId + ? agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8) + : "You"; + setReplyingTo({ commentId: comment.id, authorName, excerpt, excerptTruncated: truncated }); + editorRef.current?.focus(); + }, + [agentMap], + ); + useEffect(() => { if (!draftKey) return; setBody(loadDraft(draftKey)); + setReplyingTo(loadReplyDraft(draftKey)); }, [draftKey]); useEffect(() => { @@ -857,8 +1046,9 @@ export function CommentThread({ if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(() => { saveDraft(draftKey, body); + saveReplyDraft(draftKey, replyingTo); }, DRAFT_DEBOUNCE_MS); - }, [body, draftKey]); + }, [body, replyingTo, draftKey]); useEffect(() => { return () => { @@ -907,12 +1097,17 @@ export function CommentThread({ hasReassignment ? reassignTarget : currentAssigneeValue, ) ? true : undefined; const submittedBody = trimmed; + const submittedReply = replyingTo; setSubmitting(true); setBody(""); + setReplyingTo(null); try { - await onAdd(submittedBody, reopen, reassignment ?? undefined); - if (draftKey) clearDraft(draftKey); + await onAdd(submittedBody, reopen, reassignment ?? undefined, submittedReply?.commentId ?? null); + if (draftKey) { + clearDraft(draftKey); + saveReplyDraft(draftKey, null); + } setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -921,12 +1116,28 @@ export function CommentThread({ submittedBody, }), ); + // Restore the reply target too so a failed send can be retried with its quote intact. + setReplyingTo((current) => current ?? submittedReply); // Parent mutation handlers surface the failure and the draft is restored for retry. } finally { setSubmitting(false); } } + function clearReply() { + setReplyingTo(null); + if (draftKey) saveReplyDraft(draftKey, null); + } + + function handleComposerKeyDown(evt: KeyboardEvent) { + // Escape clears the reply target only when the editor is empty (otherwise Escape is the + // editor's own concern, e.g. dismissing the mention menu). + if (evt.key === "Escape" && replyingTo && !body.trim()) { + evt.preventDefault(); + clearReply(); + } + } + async function handleAttachFile(evt: ChangeEvent) { const file = evt.target.files?.[0]; if (!file) return; @@ -982,6 +1193,9 @@ export function CommentThread({ highlightCommentId={highlightCommentId} feedbackTermsUrl={feedbackTermsUrl} externalReferences={externalReferences} + onReply={handleReply} + onScrollToComment={scrollToComment} + deletedCommentIds={deletedCommentIds} /> {liveRunSlot} @@ -1015,6 +1229,12 @@ export function CommentThread({ highlightCommentId={highlightCommentId} queued externalReferences={externalReferences} + onScrollToComment={scrollToComment} + replyTargetDeleted={ + comment.metadata?.replyTo + ? deletedCommentIds.has(comment.metadata.replyTo.commentId) + : false + } /> ))}
@@ -1026,12 +1246,40 @@ export function CommentThread({ {composerDisabledReason} ) : ( -
+
+ {replyingTo ? ( +
+ + + +
+ ) : null} , + agentMap?: Map, +): string { + if (replyTo.authorAgentId) { + return agentMap?.get(replyTo.authorAgentId)?.name ?? replyTo.authorAgentId.slice(0, 8); + } + if (replyTo.authorType === "system") return "System"; + return "You"; +} diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 83dfcd32197..6d83f343968 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueComment } from "@paperclipai/shared"; +import type { Issue, IssueComment, IssueCommentReplyToMetadata } from "@paperclipai/shared"; export interface IssueCommentReassignment { assigneeAgentId: string | null; @@ -49,6 +49,7 @@ export function createOptimisticIssueComment(params: { authorUserId: string | null; clientStatus?: OptimisticIssueComment["clientStatus"]; queueTargetRunId?: string | null; + replyTo?: IssueCommentReplyToMetadata | null; }): OptimisticIssueComment { const now = new Date(); const clientId = createOptimisticCommentId(); @@ -62,7 +63,9 @@ export function createOptimisticIssueComment(params: { authorUserId: params.authorUserId, body: params.body, presentation: null, - metadata: null, + metadata: params.replyTo + ? { version: 1, sections: [], replyTo: params.replyTo } + : null, clientStatus: params.clientStatus ?? "pending", queueTargetRunId: params.queueTargetRunId ?? null, createdAt: now, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 2e894d4520a..ce92f7c4594 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -73,6 +73,7 @@ import { type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; +import { buildOptimisticReplyTo } from "../lib/comment-reply"; import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils"; @@ -193,6 +194,7 @@ import { type IssueRecoveryAction, type IssueAttachment, type IssueComment, + type IssueCommentReplyToMetadata, type IssueWorkProduct, type IssueWorkMode, type IssueThreadInteraction, @@ -897,7 +899,12 @@ type IssueDetailChatTabProps = { vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }, ) => Promise; - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; + onAdd: ( + body: string, + reopen?: boolean, + reassignment?: CommentReassignment, + replyToCommentId?: string | null, + ) => Promise; onImageUpload: (file: File) => Promise; onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; @@ -2408,9 +2415,15 @@ export function IssueDetail() { }); const addComment = useMutation({ - mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => - issuesApi.addComment(issueId!, body, reopen, interrupt), - onMutate: async ({ body, reopen, interrupt }) => { + mutationFn: ({ body, reopen, interrupt, replyToCommentId }: { + body: string; + reopen?: boolean; + interrupt?: boolean; + replyToCommentId?: string | null; + replyToSnapshot?: IssueCommentReplyToMetadata | null; + }) => + issuesApi.addComment(issueId!, body, reopen, interrupt, replyToCommentId), + onMutate: async ({ body, reopen, interrupt, replyToSnapshot }) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); @@ -2424,6 +2437,7 @@ export function IssueDetail() { authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", queueTargetRunId: queuedComment?.id ?? null, + replyTo: replyToSnapshot ?? null, }) : null; @@ -3565,13 +3579,26 @@ export function IssueDetail() { sharingPreferenceAtSubmit: feedbackDataSharingPreference, }); }, [feedbackDataSharingPreference, feedbackVoteMutation]); - const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { + const handleChatAdd = useCallback(async ( + body: string, + reopen?: boolean, + reassignment?: CommentReassignment, + replyToCommentId?: string | null, + ) => { if (reassignment) { + // The reassign path goes through PATCH /issues, which does not carry comment metadata, + // so an explicit reassignment cannot also attach a reply target under the current contract. await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); return; } - await addComment.mutateAsync({ body, reopen }); - }, [addComment, addCommentAndReassign]); + // Build the reply snapshot from the loaded comment so the sent quote renders optimistically; + // the server rebuilds the authoritative snapshot from replyToCommentId. + const replyTarget = replyToCommentId + ? threadComments.find((comment) => comment.id === replyToCommentId) + : undefined; + const replyToSnapshot = replyTarget ? buildOptimisticReplyTo(replyTarget) : null; + await addComment.mutateAsync({ body, reopen, replyToCommentId, replyToSnapshot }); + }, [addComment, addCommentAndReassign, threadComments]); const handleCommentImageUpload = useCallback(async (file: File) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath;