diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index c7a954d41c6..1100e555b00 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -26,6 +26,7 @@ import { type DragEvent as ReactDragEvent, type ErrorInfo, type KeyboardEvent as ReactKeyboardEvent, + type MutableRefObject, type Ref, type ReactNode, } from "react"; @@ -40,9 +41,11 @@ import type { IssueRecoveryAction, IssueRelationIssueSummary, IssueScheduledRetry, + IssueCommentReplyToMetadata, SuccessfulRunHandoffState, IssueWorkMode, } from "@paperclipai/shared"; +import { buildReplyExcerpt, formatReplyAuthorName } from "../lib/comment-reply"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; @@ -170,7 +173,7 @@ import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaLis import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Reply, Search, Square, ThumbsDown, ThumbsUp, Trash2, X } from "lucide-react"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice"; import { @@ -200,6 +203,14 @@ interface IssueChatMessageContext { onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; onDeleteComment?: (commentId: string) => Promise | void; + /** Start a composer reply targeting this comment (sets the chip + focuses the composer). */ + onReply?: (target: ReplyDraftTarget) => void; + /** Scroll to and briefly highlight a comment by id (jump-to-original from a quoted block). */ + onScrollToComment?: (commentId: string) => void; + /** Comment id currently highlighted after a jump-to-original, so its bubble can pulse. */ + highlightCommentId?: string | null; + /** Soft-deleted comment ids, so a sent reply whose target was deleted shows a tombstone. */ + deletedCommentIds?: ReadonlySet; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: @@ -416,6 +427,12 @@ interface IssueChatComposerProps { issueStatus?: string; issueWorkMode?: IssueWorkMode; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; + /** Active reply target rendered as a composer chip; controlled by the parent thread. */ + replyingTo?: ReplyDraftTarget | null; + /** Clear the active reply target (chip X button or Escape on an empty editor). */ + onClearReply?: () => void; + /** Jump to the reply target's original comment when the chip is clicked. */ + onScrollToComment?: (commentId: string) => void; } interface IssueChatThreadProps { @@ -466,7 +483,12 @@ interface IssueChatThreadProps { 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; onCancelRun?: () => Promise; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; @@ -765,6 +787,135 @@ 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. + * Mirrors the CommentThread reply draft shape so the two composers share the same persisted key. + */ +interface ReplyDraftTarget { + commentId: string; + authorName: string; + excerpt: string; + excerptTruncated: boolean; +} + +function replyDraftStorageKey(draftKey: string) { + return `${draftKey}:reply`; +} + +function loadReplyDraft(draftKey: string): ReplyDraftTarget | null { + try { + const raw = localStorage.getItem(replyDraftStorageKey(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(replyDraftStorageKey(draftKey), JSON.stringify(target)); + } else { + localStorage.removeItem(replyDraftStorageKey(draftKey)); + } + } catch { + // Ignore localStorage failures. + } +} + +/** Plain-text join of a thread message's text parts, used to seed a reply excerpt. */ +function threadMessageText(message: ThreadMessage): string { + return message.content + .filter((part): part is TextMessagePart => part.type === "text") + .map((part) => part.text) + .join("\n\n"); +} + +/** Hover-reveal reply affordance shown in a comment bubble's action row. */ +function IssueChatReplyButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +/** + * Compact quoted header rendered above a sent reply's body (accent bar + author + excerpt). + * `onAccent` adapts the treatment for the human's blue bubble; a deleted target shows a tombstone. + */ +function IssueChatReplyQuote({ + replyTo, + agentMap, + targetDeleted, + onAccent, + onClick, +}: { + replyTo: IssueCommentReplyToMetadata; + agentMap?: Map; + targetDeleted: boolean; + onAccent?: boolean; + onClick?: () => void; +}) { + const authorName = formatReplyAuthorName(replyTo, agentMap); + const barClass = onAccent ? "bg-white/60" : "bg-primary/40"; + const authorClass = onAccent ? "text-white" : "text-foreground"; + const excerptClass = onAccent ? "text-white/80" : "text-muted-foreground"; + const surfaceClass = onAccent ? "bg-white/15" : "bg-muted/40"; + const body = ( + + + + {targetDeleted ? ( + Original comment deleted + ) : ( + <> + {authorName}{" "} + + {replyTo.excerpt} + {replyTo.excerptTruncated ? "…" : ""} + + + )} + + + ); + + if (targetDeleted || !onClick) { + return
{body}
; + } + + return ( + + ); +} + function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | null { if (!target || target === "__none__") { return { assigneeAgentId: null, assigneeUserId: null }; @@ -1414,10 +1565,17 @@ function IssueChatUserMessage({ onDeleteComment, currentUserId, userProfileMap, + agentMap, + onReply, + onScrollToComment, + highlightCommentId, + deletedCommentIds, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; + const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null; + const replyTo = commentMetadata?.replyTo ?? null; const authorName = typeof custom.authorName === "string" ? custom.authorName : null; const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; @@ -1427,6 +1585,18 @@ function IssueChatUserMessage({ const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued"; const pending = custom.clientStatus === "pending"; const deleted = Boolean(custom.deletedAt); + const isOptimistic = commentId.startsWith("optimistic-"); + const canReply = Boolean(onReply) && !deleted && !pending && !queued && !isOptimistic; + const isHighlighted = Boolean(highlightCommentId && highlightCommentId === commentId); + const handleReply = () => { + const { excerpt, truncated } = buildReplyExcerpt(threadMessageText(message)); + onReply?.({ + commentId, + authorName: authorName ?? "You", + excerpt, + excerptTruncated: truncated, + }); + }; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -1518,6 +1688,15 @@ function IssueChatUserMessage({
Comment deleted
) : (
+ {replyTo ? ( + onScrollToComment(replyTo.commentId) : undefined} + /> + ) : null}
)} @@ -1573,6 +1752,7 @@ function IssueChatUserMessage({ {copied ? : } ) : null} + {canReply ? : null} {canDeleteComment ? ( + + + ) : null} + (null); + const [highlightCommentId, setHighlightCommentId] = useState(null); + const highlightTimerRef = useRef | null>(null); + + // Soft-deleted comment ids, so a sent reply whose target was deleted shows a tombstone. + const deletedCommentIds = useMemo(() => { + const ids = new Set(); + for (const message of messages) { + const custom = message.metadata?.custom as Record | undefined; + if (custom?.deletedAt && typeof custom.commentId === "string") { + ids.add(custom.commentId); + } + } + return ids; + }, [messages]); + + // Load/persist the reply draft alongside the text draft so a reload keeps the pending reply. + useEffect(() => { + if (!draftKey) return; + setReplyingTo(loadReplyDraft(draftKey)); + }, [draftKey]); + useEffect(() => { + if (!draftKey) return; + saveReplyDraft(draftKey, replyingTo); + }, [draftKey, replyingTo]); + useEffect(() => () => { + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + }, []); + + // Merge the parent-provided composer ref with an internal one so a bubble's Reply button can + // focus the composer without the parent having to thread focus back down. + const internalComposerRef = useRef(null); + const setComposerRef = useCallback((handle: IssueChatComposerHandle | null) => { + internalComposerRef.current = handle; + if (typeof composerRef === "function") composerRef(handle); + else if (composerRef) (composerRef as MutableRefObject).current = handle; + }, [composerRef]); + + const scrollAndHighlightComment = useStableEvent((commentId: string) => { + const found = scrollToThreadAnchor(`comment-${commentId}`, { align: "center" }); + if (found === false) return; + setHighlightCommentId(commentId); + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = setTimeout(() => { + setHighlightCommentId((current) => (current === commentId ? null : current)); + }, 3000); + }); + + const handleRequestReply = useStableEvent((target: ReplyDraftTarget) => { + setReplyingTo(target); + // The composer also focuses on target change; focus here too so a repeat reply on the same + // comment (target unchanged) still brings the composer into view. + internalComposerRef.current?.focus(); + }); + + const clearReply = useStableEvent(() => { + setReplyingTo(null); + }); + const runtime = usePaperclipIssueRuntime({ messages, isRunning, - onSend: ({ body, reopen, reassignment }) => { + onSend: ({ body, reopen, reassignment, replyToCommentId }) => { pendingSubmitScrollRef.current = true; - return onAdd(body, reopen, reassignment); + return onAdd(body, reopen, reassignment, replyToCommentId); }, onCancel: onCancelRun, }); @@ -4801,6 +5141,10 @@ export function IssueChatThread({ onInterruptQueued: stableOnInterruptQueued, onCancelQueued: stableOnCancelQueued, onDeleteComment: stableOnDeleteComment, + onReply: handleRequestReply, + onScrollToComment: scrollAndHighlightComment, + highlightCommentId, + deletedCommentIds, onImageClick: stableOnImageClick, onAcceptInteraction: stableOnAcceptInteraction, onRejectInteraction: stableOnRejectInteraction, @@ -4829,6 +5173,10 @@ export function IssueChatThread({ stableOnInterruptQueued, stableOnCancelQueued, stableOnDeleteComment, + handleRequestReply, + scrollAndHighlightComment, + highlightCommentId, + deletedCommentIds, stableOnImageClick, stableOnAcceptInteraction, stableOnRejectInteraction, @@ -5011,7 +5359,7 @@ export function IssueChatThread({ className="sticky bottom-(--sz-calc-8) z-20 space-y-2 bg-gradient-to-t from-background via-background/95 to-background/0 pt-6" > ) : null} diff --git a/ui/src/hooks/usePaperclipIssueRuntime.ts b/ui/src/hooks/usePaperclipIssueRuntime.ts index 6c47f10b122..36b8255116c 100644 --- a/ui/src/hooks/usePaperclipIssueRuntime.ts +++ b/ui/src/hooks/usePaperclipIssueRuntime.ts @@ -15,6 +15,7 @@ export interface PaperclipIssueRuntimeSendOptions { body: string; reopen?: boolean; reassignment?: PaperclipIssueRuntimeReassignment; + replyToCommentId?: string | null; } interface UsePaperclipIssueRuntimeOptions { @@ -74,10 +75,14 @@ export function usePaperclipIssueRuntime({ } : undefined; + const replyToCommentId = + typeof custom?.replyToCommentId === "string" && custom.replyToCommentId ? custom.replyToCommentId : null; + await onSendRef.current({ body, reopen: custom?.reopen === true ? true : undefined, reassignment, + replyToCommentId, }); }, ...(onCancel ? {