fix(ui): allow interrupting queued issue runs (#9725)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issue detail chat lets operators send messages while an issue run is live > - Messages sent during a live run are shown as queued and can expose an interrupt action > - The UI only treated running runs as interruptible, even though queued runs can also own the pending message > - That mismatch hid the interrupt button after sending a message while the agent run was still queued > - This pull request treats queued and running issue runs as interruptible and preserves the exact target run on optimistic and persisted comments > - The benefit is that operators can immediately interrupt the queued run their message is waiting behind ## Linked Issues or Issue Description - **What happened:** Sending a message while an issue-owned agent run was in `queued` state showed the message as pending but did not make the interrupt action available. - **Expected behavior:** A message queued behind either a queued or running issue run should retain that run as its interrupt target and expose the interrupt control. - **Steps to reproduce:** Open an in-progress issue with an issue-owned run still queued, send a chat message, and inspect the queued message actions. - **Version/commit:** Reproduced against the pre-change `master` UI behavior. - **Deployment mode:** Paperclip board UI with a queued issue execution run. ## What Changed - Generalized issue-run resolution from running-only to queued-or-running interruptible runs. - Used the interruptible run consistently for optimistic queue metadata, persisted comment decoration, cancel controls, and targeted interruption. - Added a regression test that sends a message behind a queued run and verifies the exact run is cancelled. ## Verification - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx` — 44 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — repository-wide gate currently reports nine pre-existing `#9627` comment literals; this patch adds no token literals or gate violations. ## Risks - Low risk: the behavior change is limited to selecting queued issue-owned runs as valid interrupt targets in the existing chat flow. - Cancellation remains targeted by run ID, and the regression test verifies the queued run ID is preserved through optimistic and persisted comment states. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI `gpt-5.4` via Codex CLI; context-window size is not exposed by this runtime; reasoning-enabled with repository, shell, GitHub CLI, and code-execution tools. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
59fb27ff79
commit
a015ac7a57
|
|
@ -240,6 +240,14 @@ vi.mock("../components/IssueChatThread", () => ({
|
|||
IssueChatThread: (props: {
|
||||
onWorkModeChange?: (workMode: string) => void;
|
||||
issueWorkMode?: string;
|
||||
comments?: Array<{
|
||||
body: string;
|
||||
clientStatus?: string;
|
||||
queueState?: string;
|
||||
queueTargetRunId?: string | null;
|
||||
}>;
|
||||
onAdd?: (body: string) => Promise<void>;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
|
|
@ -1267,6 +1275,95 @@ describe("IssueDetail", () => {
|
|||
expect(freshComment?.queueState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("queues messages against a queued live run and interrupts that exact run", async () => {
|
||||
const postedComment = createDeferred<IssueComment>();
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_progress",
|
||||
executionRunId: "run-queued",
|
||||
}));
|
||||
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
|
||||
mockHeartbeatsApi.cancel.mockResolvedValue({});
|
||||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
|
||||
{
|
||||
id: "run-queued",
|
||||
status: "queued",
|
||||
invocationSource: "issue",
|
||||
triggerDetail: null,
|
||||
contextCommentId: null,
|
||||
contextWakeCommentId: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-21T00:00:01.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Coder",
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-1",
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
onAdd: (body: string) => Promise<void>;
|
||||
};
|
||||
await act(async () => {
|
||||
void props.onAdd("Queued run message");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const queuedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<{
|
||||
body: string;
|
||||
clientStatus?: string;
|
||||
queueState?: string;
|
||||
queueTargetRunId?: string | null;
|
||||
}>;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
};
|
||||
const optimisticComment = queuedProps.comments?.find((comment) => comment.body === "Queued run message");
|
||||
expect(optimisticComment).toMatchObject({
|
||||
clientStatus: "queued",
|
||||
queueState: "queued",
|
||||
queueTargetRunId: "run-queued",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
postedComment.resolve(createIssueComment({ body: "Queued run message" }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const persistedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<{
|
||||
body: string;
|
||||
clientStatus?: string;
|
||||
queueState?: string;
|
||||
queueTargetRunId?: string | null;
|
||||
}>;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
};
|
||||
const persistedComment = persistedProps.comments?.find((comment) => comment.body === "Queued run message");
|
||||
expect(persistedComment).toMatchObject({
|
||||
queueState: "queued",
|
||||
queueTargetRunId: "run-queued",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await persistedProps.onInterruptQueued(persistedComment!.queueTargetRunId!);
|
||||
});
|
||||
|
||||
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-queued");
|
||||
mockHeartbeatsApi.cancel.mockClear();
|
||||
});
|
||||
|
||||
it("does not optimistically queue a fresh comment from an unlocked stale active-run cache", async () => {
|
||||
const postedComment = createDeferred<IssueComment>();
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
|
|
|
|||
|
|
@ -316,12 +316,19 @@ export function shouldScrollIssueDetailToTopOnNavigation(input: {
|
|||
return input.previousIssueId !== input.nextIssueId;
|
||||
}
|
||||
|
||||
function resolveRunningIssueRun(
|
||||
function resolveInterruptibleIssueRun(
|
||||
activeRun: ActiveRunForIssue | null | undefined,
|
||||
liveRuns: readonly LiveRunForIssue[] | undefined,
|
||||
) {
|
||||
const runningLiveRun = (liveRuns ?? []).find((run) => run.status === "running") ?? null;
|
||||
return runningLiveRun ?? (activeRun?.status === "running" ? activeRun : null);
|
||||
const issueLiveRun =
|
||||
(liveRuns ?? []).find((run) => run.status === "running") ??
|
||||
(liveRuns ?? []).find((run) => run.status === "queued") ??
|
||||
null;
|
||||
return issueLiveRun ?? (
|
||||
activeRun?.status === "running" || activeRun?.status === "queued"
|
||||
? activeRun
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
|
||||
|
|
@ -354,7 +361,7 @@ function readIssueRunStateFromCache(
|
|||
return {
|
||||
liveRuns,
|
||||
activeRun: resolvedActiveRun,
|
||||
runningIssueRun: resolveRunningIssueRun(resolvedActiveRun, liveRuns),
|
||||
interruptibleIssueRun: resolveInterruptibleIssueRun(resolvedActiveRun, liveRuns),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1037,8 +1044,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
const resolvedActivity = activity ?? [];
|
||||
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||
|
||||
const runningIssueRun = useMemo(
|
||||
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
|
||||
const interruptibleIssueRun = useMemo(
|
||||
() => resolveInterruptibleIssueRun(resolvedActiveRun, resolvedLiveRuns),
|
||||
[resolvedActiveRun, resolvedLiveRuns],
|
||||
);
|
||||
const liveRunIds = useMemo(() => {
|
||||
|
|
@ -1058,7 +1065,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
}));
|
||||
}, [liveRunIds, resolvedLinkedRuns]);
|
||||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||
const activeRunStartedAt = interruptibleIssueRun?.startedAt ?? interruptibleIssueRun?.createdAt ?? null;
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||
const followUpCommentIds = new Set<string>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
|
|
@ -1099,7 +1106,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
|
||||
queuedTargetRunId,
|
||||
targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false,
|
||||
runningRunId: runningIssueRun?.id ?? null,
|
||||
runningRunId: interruptibleIssueRun?.id ?? null,
|
||||
});
|
||||
if (locallyQueuedComment !== nextComment) {
|
||||
return locallyQueuedComment;
|
||||
|
|
@ -1108,9 +1115,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
activeRunStartedAt,
|
||||
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
||||
activeRunCommentId: runningIssueRun?.contextCommentId ?? null,
|
||||
activeRunWakeCommentId: runningIssueRun?.contextWakeCommentId ?? null,
|
||||
activeRunAgentId: interruptibleIssueRun?.agentId ?? null,
|
||||
activeRunCommentId: interruptibleIssueRun?.contextCommentId ?? null,
|
||||
activeRunWakeCommentId: interruptibleIssueRun?.contextWakeCommentId ?? null,
|
||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||
})
|
||||
|
|
@ -1118,7 +1125,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
return {
|
||||
...nextComment,
|
||||
queueState: "queued" as const,
|
||||
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
||||
queueTargetRunId: interruptibleIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
||||
queueReason: queuedCommentReason,
|
||||
};
|
||||
}
|
||||
|
|
@ -1131,7 +1138,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
queuedCommentReason,
|
||||
resolvedActivity,
|
||||
resolvedLinkedRuns,
|
||||
runningIssueRun,
|
||||
interruptibleIssueRun,
|
||||
]);
|
||||
const timelineEvents = useMemo(
|
||||
() => extractIssueTimelineEvents(resolvedActivity),
|
||||
|
|
@ -1220,9 +1227,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
issueWorkMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
onCancelRun={runningIssueRun && onPauseWorkRun
|
||||
onCancelRun={interruptibleIssueRun && onPauseWorkRun
|
||||
? async () => {
|
||||
await onPauseWorkRun(runningIssueRun.id);
|
||||
await onPauseWorkRun(interruptibleIssueRun.id);
|
||||
}
|
||||
: undefined}
|
||||
onImageClick={onImageClick}
|
||||
|
|
@ -2415,7 +2422,9 @@ export function IssueDetail() {
|
|||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null;
|
||||
const queuedComment = !interrupt
|
||||
? readIssueRunStateFromCache(queryClient, issueId!, issue).interruptibleIssueRun
|
||||
: null;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
|
|
@ -2673,7 +2682,9 @@ export function IssueDetail() {
|
|||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null;
|
||||
const queuedComment = !interrupt
|
||||
? readIssueRunStateFromCache(queryClient, issueId!, issue).interruptibleIssueRun
|
||||
: null;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
|
|
@ -2793,11 +2804,11 @@ export function IssueDetail() {
|
|||
previousRunState.find((state) => state.activeRun)?.activeRun ??
|
||||
null;
|
||||
const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? []));
|
||||
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
|
||||
const interruptibleIssueRun = resolveInterruptibleIssueRun(cachedActiveRun, liveRunList);
|
||||
const targetRun =
|
||||
cachedActiveRun?.id === runId
|
||||
? cachedActiveRun
|
||||
: liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null;
|
||||
: liveRunList?.find((run) => run.id === runId) ?? interruptibleIssueRun ?? null;
|
||||
|
||||
if (targetRun) {
|
||||
const interruptedAt = new Date().toISOString();
|
||||
|
|
|
|||
Loading…
Reference in New Issue