mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(ui): reply-to-comment in the live IssueChatThread (P3)
The live issue page renders IssueChatThread, not CommentThread, so port the reply-to-comment UI there per the CTO routing decision: - onAdd/runtime gain a 4th replyToCommentId param; the composer forwards it via runConfig.custom (dropped on the reassign path, matching the server contract) - Composer reply chip (quoted/clamped source, jump-to-original, X to clear, Escape-to-clear on empty editor, persisted via the shared :reply draft key) - Hover Reply button on human + genuine agent comment bubbles - Sent quoted block above replies (accent-aware for the blue human bubble), jump-to-original scroll + highlight, and deleted-source tombstone - Reply state lifted into IssueChatThread and exposed to bubbles via context; composer ref merged so a bubble Reply focuses the composer Existing CommentThread implementation + shared comment-reply helpers unchanged. tsc -b ui green; issue-chat-messages tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
594bc5b92b
commit
0fb4f879f2
2 files changed
+364
-8
No files matched your search
@@ -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<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | 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<string>;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction:
|
||||
@@ -416,6 +427,12 @@ interface IssueChatComposerProps {
|
||||
issueStatus?: string;
|
||||
issueWorkMode?: IssueWorkMode;
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | 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<void>;
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onAdd: (
|
||||
body: string,
|
||||
reopen?: boolean,
|
||||
reassignment?: CommentReassignment,
|
||||
replyToCommentId?: string | null,
|
||||
) => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
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<ReplyDraftTarget>;
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title="Reply"
|
||||
aria-label="Reply to comment"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Reply</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, Agent>;
|
||||
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 = (
|
||||
<span className="flex min-w-0 items-stretch gap-2">
|
||||
<span aria-hidden className={cn("w-0.5 shrink-0 rounded-full", barClass)} />
|
||||
<span className="min-w-0 flex-1 py-0.5 text-xs">
|
||||
{targetDeleted ? (
|
||||
<span className={cn("italic", excerptClass)}>Original comment deleted</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn("font-medium", authorClass)}>{authorName}</span>{" "}
|
||||
<span className={cn("line-clamp-2 align-baseline", excerptClass)}>
|
||||
{replyTo.excerpt}
|
||||
{replyTo.excerptTruncated ? "…" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (targetDeleted || !onClick) {
|
||||
return <div className={cn("mb-1.5 rounded-sm px-2 py-1", surfaceClass)}>{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title="Jump to the original comment"
|
||||
className={cn(
|
||||
"mb-1.5 block w-full rounded-sm px-2 py-1 text-left transition-colors",
|
||||
onAccent ? "bg-white/15 hover:bg-white/25" : "bg-muted/40 hover:bg-muted/70",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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({
|
||||
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
|
||||
) : (
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
{replyTo ? (
|
||||
<IssueChatReplyQuote
|
||||
replyTo={replyTo}
|
||||
agentMap={agentMap}
|
||||
targetDeleted={deletedCommentIds?.has(replyTo.commentId) ?? false}
|
||||
onAccent={isCurrentUser && !queued}
|
||||
onClick={onScrollToComment ? () => onScrollToComment(replyTo.commentId) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<IssueChatTextParts message={message} onAccent={isCurrentUser && !queued} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1573,6 +1752,7 @@ function IssueChatUserMessage({
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
{canReply ? <IssueChatReplyButton onClick={handleReply} /> : null}
|
||||
{canDeleteComment ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1591,7 +1771,13 @@ function IssueChatUserMessage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={anchorId}>
|
||||
<div
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
"rounded-2xl transition-colors duration-1000",
|
||||
isHighlighted && "bg-primary/10 ring-2 ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className={cn("group flex items-end gap-2", isCurrentUser && "justify-end")}>
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
@@ -1649,9 +1835,15 @@ function IssueChatAssistantMessage({
|
||||
stoppingRunLabel = "Stopping...",
|
||||
stopRunVariant = "stop",
|
||||
runFinalizationActions = [],
|
||||
onReply,
|
||||
onScrollToComment,
|
||||
highlightCommentId,
|
||||
deletedCommentIds,
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
|
||||
const replyTo = commentMetadata?.replyTo ?? null;
|
||||
const authorName = typeof custom.authorName === "string"
|
||||
? custom.authorName
|
||||
: typeof custom.runAgentName === "string"
|
||||
@@ -1720,6 +1912,20 @@ function IssueChatAssistantMessage({
|
||||
const isGenuineComment =
|
||||
kind === "comment" && !!commentId && !isRunning && (hasCommentText || deleted);
|
||||
|
||||
const isOptimistic = commentId?.startsWith("optimistic-") ?? false;
|
||||
const canReply = Boolean(onReply) && isGenuineComment && !!commentId && !deleted && !isOptimistic;
|
||||
const isHighlighted = Boolean(highlightCommentId && commentId && highlightCommentId === commentId);
|
||||
const handleReply = () => {
|
||||
if (!commentId) return;
|
||||
const { excerpt, truncated } = buildReplyExcerpt(threadMessageText(message));
|
||||
onReply?.({
|
||||
commentId,
|
||||
authorName,
|
||||
excerpt,
|
||||
excerptTruncated: truncated,
|
||||
});
|
||||
};
|
||||
|
||||
const agentAvatar = (
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
{agentIcon ? (
|
||||
@@ -1760,6 +1966,7 @@ function IssueChatAssistantMessage({
|
||||
onVote={handleVote}
|
||||
/>
|
||||
) : null}
|
||||
{canReply ? <IssueChatReplyButton onClick={handleReply} /> : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
@@ -1837,7 +2044,13 @@ function IssueChatAssistantMessage({
|
||||
// blue bubble in IssueChatUserMessage). See PAP-95 rev 6.
|
||||
if (isGenuineComment) {
|
||||
return (
|
||||
<div id={anchorId}>
|
||||
<div
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
"rounded-2xl transition-colors duration-1000",
|
||||
isHighlighted && "bg-primary/10 ring-2 ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="group flex flex-col items-start py-1.5">
|
||||
{/* Icon + name together in a header ABOVE the bubble (PAP-95 rev 7). */}
|
||||
<div className="mb-1 flex items-center gap-1.5 px-1">
|
||||
@@ -1872,6 +2085,14 @@ function IssueChatAssistantMessage({
|
||||
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
|
||||
) : (
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
{replyTo ? (
|
||||
<IssueChatReplyQuote
|
||||
replyTo={replyTo}
|
||||
agentMap={agentMap}
|
||||
targetDeleted={deletedCommentIds?.has(replyTo.commentId) ?? false}
|
||||
onClick={onScrollToComment ? () => onScrollToComment(replyTo.commentId) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<IssueChatAssistantParts message={message} hasCoT={false} />
|
||||
{notices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -3540,6 +3761,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
issueStatus,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
replyingTo = null,
|
||||
onClearReply,
|
||||
onScrollToComment,
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const [body, setBody] = useState("");
|
||||
@@ -3586,6 +3810,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
// Focus the editor when a reply target is newly selected (e.g. the user clicked Reply on a
|
||||
// bubble). The first run is skipped so a reply draft rehydrated on load does not steal focus.
|
||||
const previousReplyCommentIdRef = useRef<string | null>(replyingTo?.commentId ?? null);
|
||||
useEffect(() => {
|
||||
const nextReplyCommentId = replyingTo?.commentId ?? null;
|
||||
if (nextReplyCommentId && nextReplyCommentId !== previousReplyCommentIdRef.current) {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
previousReplyCommentIdRef.current = nextReplyCommentId;
|
||||
}, [replyingTo?.commentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
@@ -3645,6 +3880,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||
) ? true : undefined;
|
||||
const submittedBody = trimmed;
|
||||
// An explicit reassignment goes through PATCH /issues, which cannot carry comment metadata,
|
||||
// so a reply target cannot compose with a reassign under the current contract (the upstream
|
||||
// handler drops it). Only forward the reply id on the plain comment path.
|
||||
const replyToCommentId = !reassignment ? replyingTo?.commentId ?? null : null;
|
||||
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||
|
||||
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
|
||||
@@ -3663,12 +3902,14 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
custom: {
|
||||
...(reopen ? { reopen: true } : {}),
|
||||
...(reassignment ? { reassignment } : {}),
|
||||
...(replyToCommentId ? { replyToCommentId } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
await appendPromise;
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
onClearReply?.();
|
||||
setComposerAttachments([]);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
@@ -3877,6 +4118,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
|
||||
|
||||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
// Escape clears the reply target, but only when the editor is empty so it does not swallow
|
||||
// the editor's own Escape (e.g. dismissing the mention menu) while the user is typing.
|
||||
if (evt.key === "Escape" && replyingTo && !body.trim()) {
|
||||
evt.preventDefault();
|
||||
onClearReply?.();
|
||||
return;
|
||||
}
|
||||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||||
@@ -3923,11 +4171,43 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replyingTo ? (
|
||||
<div
|
||||
data-testid="issue-chat-composer-reply-chip"
|
||||
className="mb-2 flex items-stretch gap-2 rounded-md border border-border bg-muted/40 py-1.5 pl-2 pr-1.5"
|
||||
>
|
||||
<span aria-hidden className="w-0.5 shrink-0 rounded-full bg-primary/60" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScrollToComment?.(replyingTo.commentId)}
|
||||
title="Jump to the original comment"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
Replying to {replyingTo.authorName}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{replyingTo.excerpt}
|
||||
{replyingTo.excerptTruncated ? "…" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClearReply?.()}
|
||||
title="Cancel reply"
|
||||
aria-label="Cancel reply"
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center self-center rounded-md text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Reply"
|
||||
placeholder={replyingTo ? `Reply to ${replyingTo.authorName}...` : "Reply"}
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={onImageUpload}
|
||||
@@ -4459,12 +4739,72 @@ export function IssueChatThread({
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Reply-to-comment composer state (P3, PAP-13802) ---
|
||||
const [replyingTo, setReplyingTo] = useState<ReplyDraftTarget | null>(null);
|
||||
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Soft-deleted comment ids, so a sent reply whose target was deleted shows a tombstone.
|
||||
const deletedCommentIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const custom = message.metadata?.custom as Record<string, unknown> | 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<IssueChatComposerHandle | null>(null);
|
||||
const setComposerRef = useCallback((handle: IssueChatComposerHandle | null) => {
|
||||
internalComposerRef.current = handle;
|
||||
if (typeof composerRef === "function") composerRef(handle);
|
||||
else if (composerRef) (composerRef as MutableRefObject<IssueChatComposerHandle | null>).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"
|
||||
>
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
ref={setComposerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
@@ -5029,6 +5377,9 @@ export function IssueChatThread({
|
||||
issueStatus={issueStatus}
|
||||
issueWorkMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
replyingTo={replyingTo}
|
||||
onClearReply={clearReply}
|
||||
onScrollToComment={scrollAndHighlightComment}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
Reference in new issue
Block a user