[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
This commit is contained in:
Dotta
2026-04-21 16:50:26 -05:00
committed by GitHub
parent bcbbb41a4b
commit 014aa0eb2d
3 changed files with 47 additions and 13 deletions

View File

@@ -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);
});
});

View File

@@ -91,12 +91,12 @@ export function applyLocalQueuedIssueCommentState<T extends IssueComment>(
comment: T,
params: {
queuedTargetRunId?: string | null;
hasLiveRuns: boolean;
targetRunIsLive: boolean;
runningRunId?: string | null;
},
): T | LocallyQueuedIssueComment<T> {
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 {

View File

@@ -613,19 +613,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
[resolvedActiveRun, resolvedLiveRuns],
);
const liveRunIds = useMemo(() => {
const ids = new Set<string>();
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<string>();
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<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
@@ -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<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
const previousIssue = queryClient.getQueryData<Issue>(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",