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:
Dotta 2026-07-16 16:55:51 -05:00 committed by GitHub
parent 59fb27ff79
commit a015ac7a57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 127 additions and 19 deletions

View File

@ -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({

View File

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