From 014aa0eb2d1425359313192141dc9b41285b8096 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:50:26 -0500 Subject: [PATCH] [codex] Clear stale queued comment targets (#4234) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators interact with agent work through issue threads and queued comments. > - When the selected comment target becomes stale, the composer can keep pointing at an invalid target after thread state changes. > - That makes follow-up comments easier to misroute and harder to reason about. > - This pull request clears stale queued comment targets and covers the behavior with tests. > - The benefit is more predictable issue-thread commenting during live agent work. ## What Changed - Clears queued comment targets when they no longer match the current issue thread state. - Adjusts issue detail comment-target handling to avoid stale target reuse. - Adds regression tests for optimistic issue comment target behavior. ## Verification - `pnpm exec vitest run ui/src/lib/optimistic-issue-comments.test.ts` ## Risks - Low risk; scoped to comment-target state handling in the issue UI. - No migrations. > Checked `ROADMAP.md`; this is a focused UI reliability fix, not a new roadmap-level feature. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled repository editing and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/lib/optimistic-issue-comments.test.ts | 25 ++++++++++++++-- ui/src/lib/optimistic-issue-comments.ts | 4 +-- ui/src/pages/IssueDetail.tsx | 31 ++++++++++++++------ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index d9a0a337..1c53fb7f 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -720,7 +720,7 @@ describe("optimistic issue comments", () => { const result = applyLocalQueuedIssueCommentState(comment, { queuedTargetRunId: "run-1", - hasLiveRuns: true, + targetRunIsLive: true, runningRunId: "run-1", }); @@ -746,10 +746,31 @@ describe("optimistic issue comments", () => { const result = applyLocalQueuedIssueCommentState(comment, { queuedTargetRunId: "run-1", - hasLiveRuns: false, + targetRunIsLive: false, runningRunId: null, }); expect(result).toBe(comment); }); + + it("does not keep local queued state when a different run is live", () => { + const comment = { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Follow up after the active run", + createdAt: new Date("2026-03-28T16:20:05.000Z"), + updatedAt: new Date("2026-03-28T16:20:05.000Z"), + }; + + const result = applyLocalQueuedIssueCommentState(comment, { + queuedTargetRunId: "run-1", + targetRunIsLive: true, + runningRunId: "run-2", + }); + + expect(result).toBe(comment); + }); }); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 30cf5cba..f35a484c 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -91,12 +91,12 @@ export function applyLocalQueuedIssueCommentState( comment: T, params: { queuedTargetRunId?: string | null; - hasLiveRuns: boolean; + targetRunIsLive: boolean; runningRunId?: string | null; }, ): T | LocallyQueuedIssueComment { const queuedTargetRunId = params.queuedTargetRunId ?? null; - if (!queuedTargetRunId || !params.hasLiveRuns) return comment; + if (!queuedTargetRunId || !params.targetRunIsLive) return comment; if (params.runningRunId && params.runningRunId !== queuedTargetRunId) return comment; return { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 17183622..f6276248 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -613,19 +613,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), [resolvedActiveRun, resolvedLiveRuns], ); + const liveRunIds = useMemo(() => { + const ids = new Set(); + for (const run of resolvedLiveRuns) ids.add(run.id); + if (resolvedActiveRun) ids.add(resolvedActiveRun.id); + return ids; + }, [resolvedActiveRun, resolvedLiveRuns]); const timelineRuns = useMemo(() => { - const liveIds = new Set(); - for (const run of resolvedLiveRuns) liveIds.add(run.id); - if (activeRun) liveIds.add(activeRun.id); - const historicalRuns = liveIds.size === 0 + const historicalRuns = liveRunIds.size === 0 ? resolvedLinkedRuns - : resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId)); + : resolvedLinkedRuns.filter((run) => !liveRunIds.has(run.runId)); return historicalRuns.map((run) => ({ ...run, adapterType: run.adapterType, hasStoredOutput: (run.logBytes ?? 0) > 0, })); - }, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]); + }, [liveRunIds, resolvedLinkedRuns]); const commentsWithRunMeta = useMemo(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); @@ -651,9 +654,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ return comments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; + const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null; const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { - queuedTargetRunId: locallyQueuedCommentRunIds.get(comment.id) ?? null, - hasLiveRuns, + queuedTargetRunId, + targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false, runningRunId: runningIssueRun?.id ?? null, }); if (locallyQueuedComment !== nextComment) { @@ -676,7 +680,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ } return nextComment; }); - }, [comments, hasLiveRuns, locallyQueuedCommentRunIds, resolvedActivity, resolvedLinkedRuns, runningIssueRun]); + }, [comments, liveRunIds, locallyQueuedCommentRunIds, resolvedActivity, resolvedLinkedRuns, runningIssueRun]); const timelineEvents = useMemo( () => extractIssueTimelineEvents(resolvedActivity), [resolvedActivity], @@ -1625,6 +1629,7 @@ export function IssueDetail() { const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds; const liveRunList = previousLiveRuns ?? []; const cachedActiveRun = previousActiveRun ?? null; const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); @@ -1653,12 +1658,17 @@ export function IssueDetail() { queryKeys.issues.detail(issueId!), (current: Issue | undefined) => clearIssueExecutionRun(current, runId), ); + setLocallyQueuedCommentRunIds((current) => { + const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId)); + return next.size === current.size ? current : next; + }); return { previousRuns, previousLiveRuns, previousActiveRun, previousIssue, + previousLocalQueuedCommentRunIds, }; }, onSuccess: () => { @@ -1675,6 +1685,9 @@ export function IssueDetail() { queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns); queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun); queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue); + if (context?.previousLocalQueuedCommentRunIds) { + setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds); + } pushToast({ title: "Interrupt failed", body: err instanceof Error ? err.message : "Unable to interrupt the active run",