feat(ui): reply-to-comment thread UI (P3)

Implement the reply-to-comment thread UI per the reply-to-comment plan (design D3) against the
P2 replyTo types/API:

- Reply icon button (hover-reveal on desktop, always-on for touch) in each
  non-deleted, persisted comment's toolbar.
- Composer reply chip with accent bar, "Replying to {author}", clamped
  excerpt, and X; Escape clears when the editor is empty; target persists
  with the localStorage draft (debounced) and survives reload.
- Sends metadata.replyTo.commentId through issuesApi.addComment; server
  builds the authoritative snapshot.
- Sent replies render a compact quoted header (clickable → scroll+highlight
  original) with an "Original comment deleted" tombstone when the target is
  soft-deleted.
- Optimistic quote via a client-built replyTo snapshot on the addComment
  mutation.

Shared helper lib (comment-reply.ts) is surface-agnostic. Export
IssueCommentReplyToMetadata from the shared barrel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-07-13 03:06:39 +00:00
1 parent b91d3a067e
commit 594bc5b92b
7 files changed
+354 -20

No files matched your search

+1
View File
@@ -759,6 +759,7 @@ export type {
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentReplyToMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
+1
View File
@@ -387,6 +387,7 @@ export type {
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentReplyToMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
+8 -1
View File
@@ -284,13 +284,20 @@ export const issuesApi = {
allowSharing?: boolean;
},
) => api.post<FeedbackVote>(`/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<IssueComment>(
`/issues/${id}/comments`,
{
body,
...(reopen === undefined ? {} : { reopen }),
...(interrupt === undefined ? {} : { interrupt }),
...(replyToCommentId ? { metadata: { replyTo: { commentId: replyToCommentId } } } : {}),
},
),
cancelComment: (id: string, commentId: string) =>
+258 -10
View File
@@ -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<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onAdd: (
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
replyToCommentId?: string | null,
) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
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<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(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 (
<button
type="button"
onClick={onClick}
title="Reply"
aria-label="Reply to comment"
className={cn(
"inline-flex min-h-8 items-center justify-center rounded-md px-2 text-muted-foreground transition-[color,background-color,opacity] hover:bg-accent/60 hover:text-foreground",
// Hover-reveal on hover-capable pointers; always visible where hover is unavailable (touch).
"opacity-100 [@media(hover:hover)]:opacity-0 group-hover/comment:opacity-100 focus-visible:opacity-100",
)}
>
<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). */
function ReplyQuote({
replyTo,
agentMap,
targetDeleted,
onClick,
}: {
replyTo: IssueCommentReplyToMetadata;
agentMap?: Map<string, Agent>;
targetDeleted: boolean;
onClick?: () => void;
}) {
const authorName = formatReplyAuthorName(replyTo, agentMap);
const body = (
<span className="flex min-w-0 items-stretch gap-2">
<span aria-hidden className="w-0.5 shrink-0 rounded-full bg-primary/40" />
<span className="min-w-0 flex-1 py-0.5 text-xs">
{targetDeleted ? (
<span className="italic text-muted-foreground">Original comment deleted</span>
) : (
<>
<span className="font-medium text-foreground">{authorName}</span>{" "}
<span className="text-muted-foreground line-clamp-2 align-baseline">
{replyTo.excerpt}
{replyTo.excerptTruncated ? "…" : ""}
</span>
</>
)}
</span>
</span>
);
if (targetDeleted || !onClick) {
return <div className="mb-1.5 rounded-sm bg-muted/40 px-2 py-1">{body}</div>;
}
return (
<button
type="button"
onClick={onClick}
title="Jump to the original comment"
className="mb-1.5 block w-full rounded-sm bg-muted/40 px-2 py-1 text-left transition-colors hover:bg-muted/70"
>
{body}
</button>
);
}
function CommentCard({
comment,
agentMap,
@@ -326,6 +444,9 @@ function CommentCard({
highlightCommentId,
queued = false,
externalReferences,
onReply,
onScrollToComment,
replyTargetDeleted = false,
}: {
comment: CommentWithRunMeta;
agentMap?: Map<string, Agent>;
@@ -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 (
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
className={`group/comment 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
@@ -409,13 +536,28 @@ function CommentCard({
{formatDateTime(comment.createdAt)}
</a>
)}
{canReply ? <ReplyButton onClick={() => onReply?.(comment)} /> : null}
{!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>
<>
{replyTo ? (
<ReplyQuote
replyTo={replyTo}
agentMap={agentMap}
targetDeleted={replyTargetDeleted}
onClick={
onScrollToComment && !replyTargetDeleted
? () => onScrollToComment(replyTo.commentId)
: undefined
}
/>
) : null}
<MarkdownBody className="text-sm" softBreaks externalReferences={externalReferences}>{comment.body}</MarkdownBody>
</>
)}
{companyId && !isPending && !isDeleted ? (
<div className="mt-2 space-y-2">
@@ -579,6 +721,9 @@ const TimelineList = memo(function TimelineList({
votingTargetId,
highlightCommentId,
externalReferences,
onReply,
onScrollToComment,
deletedCommentIds,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
@@ -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<string>;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
@@ -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<string | null>(null);
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
const [replyingTo, setReplyingTo] = useState<ReplyDraftTarget | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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<string>();
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<HTMLDivElement>) {
// 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<HTMLInputElement>) {
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
}
/>
))}
</div>
@@ -1026,12 +1246,40 @@ export function CommentThread({
{composerDisabledReason}
</div>
) : (
<div className="space-y-2">
<div className="space-y-2" onKeyDown={handleComposerKeyDown}>
{replyingTo ? (
<div className="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={() => scrollToComment(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={clearReply}
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="Leave a comment..."
placeholder={replyingTo ? `Reply to ${replyingTo.authorName}...` : "Leave a comment..."}
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
+47
View File
@@ -0,0 +1,47 @@
import type { Agent, IssueComment, IssueCommentReplyToMetadata } from "@paperclipai/shared";
/**
* Max characters shown in a reply excerpt (composer chip + sent quoted header).
* Mirrors the server-authored snapshot length so optimistic and persisted quotes match.
*/
export const REPLY_EXCERPT_MAX_LENGTH = 200;
/** Collapse markdown/whitespace into a single-line excerpt clamped to {@link REPLY_EXCERPT_MAX_LENGTH}. */
export function buildReplyExcerpt(body: string): { excerpt: string; truncated: boolean } {
const normalized = body.replace(/\s+/g, " ").trim();
if (normalized.length <= REPLY_EXCERPT_MAX_LENGTH) {
return { excerpt: normalized, truncated: false };
}
return { excerpt: normalized.slice(0, REPLY_EXCERPT_MAX_LENGTH).trimEnd(), truncated: true };
}
/**
* Build the reply snapshot the server will also produce, so the sent quote renders immediately
* (optimistic update) and stays consistent after the authoritative refetch.
*/
export function buildOptimisticReplyTo(target: IssueComment): IssueCommentReplyToMetadata {
const { excerpt, truncated } = buildReplyExcerpt(target.body);
return {
commentId: target.id,
authorType: target.authorType,
authorAgentId: target.authorAgentId ?? null,
authorUserId: target.authorUserId ?? null,
excerpt,
excerptTruncated: truncated,
};
}
/**
* Human-readable author label for a reply target. Mirrors {@link CommentCard}'s author rendering:
* agent comments show the agent name, everything else collapses to "You".
*/
export function formatReplyAuthorName(
replyTo: Pick<IssueCommentReplyToMetadata, "authorType" | "authorAgentId">,
agentMap?: Map<string, Agent>,
): string {
if (replyTo.authorAgentId) {
return agentMap?.get(replyTo.authorAgentId)?.name ?? replyTo.authorAgentId.slice(0, 8);
}
if (replyTo.authorType === "system") return "System";
return "You";
}
+5 -2
View File
@@ -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,
+34 -7
View File
@@ -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<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onAdd: (
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
replyToCommentId?: string | null,
) => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
onInterruptQueued: (runId: string) => Promise<void>;
@@ -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;