diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 3673336ab..36a2275bc 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -109,6 +109,7 @@ import { updateCommentCodeContextExpansionState, type CommentCodeContextLineUpdate } from '@/components/comment-code-context-state' +import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -2380,12 +2381,13 @@ function ConversationTab({ [commentFilter, comments] ) const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) + const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) - useEffect(() => { - if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { - setReplyingTo(null) - } - }, [replyingTo, visibleComments]) + if (resolvedReplyingTo !== replyingTo) { + // Why: comment filters/refetches can hide the active reply target; clear it + // before paint so a stale composer does not flash for the wrong comment set. + setReplyingTo(resolvedReplyingTo) + } useEffect(() => { if (!bodyEditing) { @@ -2590,7 +2592,7 @@ function ConversationTab({ className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full" /> - {replyingTo === comment.id && ( + {resolvedReplyingTo === comment.id && ( groupPRComments(visibleComments), [visibleComments]) + const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) const mentionOptions = useMemo( () => buildMentionOptions({ @@ -2589,11 +2591,11 @@ function ConversationTab({ } }, []) - useEffect(() => { - if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { - setReplyingTo(null) - } - }, [replyingTo, visibleComments]) + if (resolvedReplyingTo !== replyingTo) { + // Why: comment filters/refetches can hide the active reply target; clear it + // before paint so a stale composer does not flash for the wrong comment set. + setReplyingTo(resolvedReplyingTo) + } useEffect(() => { if (!bodyEditing) { @@ -2811,7 +2813,7 @@ function ConversationTab({ className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full" /> - {replyingTo === comment.id && ( + {resolvedReplyingTo === comment.id && ( { + it('preserves a visible reply target', () => { + expect(resolveCommentReplyTarget(2, [{ id: 1 }, { id: 2 }])).toBe(2) + }) + + it('clears a hidden reply target', () => { + expect(resolveCommentReplyTarget(3, [{ id: 1 }, { id: 2 }])).toBeNull() + }) + + it('keeps an empty reply target empty', () => { + expect(resolveCommentReplyTarget(null, [{ id: 1 }])).toBeNull() + }) +}) diff --git a/src/renderer/src/components/comment-reply-target-state.ts b/src/renderer/src/components/comment-reply-target-state.ts new file mode 100644 index 000000000..f25d9a694 --- /dev/null +++ b/src/renderer/src/components/comment-reply-target-state.ts @@ -0,0 +1,13 @@ +export type CommentReplyTargetComment = { + id: number +} + +export function resolveCommentReplyTarget( + replyingTo: number | null, + visibleComments: readonly CommentReplyTargetComment[] +): number | null { + if (replyingTo === null) { + return null + } + return visibleComments.some((comment) => comment.id === replyingTo) ? replyingTo : null +}