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
+}