fix(ui): restore queued message interrupt action (#11374)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task thread lets an operator add guidance while an agent run is
active
> - A new message can wait behind that active run as a queued message
> - The classic task view lets the operator interrupt the target run
from that queued message
> - The redesigned task view did not expose the same action
> - This pull request restores the action and keeps it bound to the
exact target run
> - The benefit is that operators can apply urgent guidance without
switching task views

## Linked Issues or Issue Description

**What happened?**

The redesigned task view showed `Queued` for a queued operator message,
but it did not show the existing interrupt action.

**Expected behavior**

The queued message must show `Interrupt` next to `Queued`. The action
must stop the exact run that the message is waiting behind.

**Steps to reproduce**

1. Open a task in the redesigned task view while an agent run is active.
2. Send a new operator message so it enters the queued state.
3. Observe that the queued message has no interrupt action.

**Paperclip version or commit**

`bc0b5a1642`

**Deployment mode**

All deployment modes that use the redesigned task view.

## What Changed

- Preserve persisted queued state and the target run ID in the
redesigned thread model.
- Render a token-compliant `Interrupt` action beside the queued state.
- Reuse the existing exact-run interrupt callback and show a disabled
`Interrupting…` state during the request.
- Keep an assigned queue target immutable so an in-flight comment cannot
rebind its interrupt action to a replacement run.
- Add regression tests for persisted queued messages, replacement-run
races, and the in-progress action state.
- No documentation update was required because this restores existing
behavior.

## Verification

- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx`
- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx
ui/src/components/task-chat/task-chat-adapter.test.ts
ui/src/pages/IssueDetail.test.tsx -t 'queued message actions|queues
messages against a queued live run and interrupts that exact
run|commentsToTaskChatItems'`
- `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx -t 'queued
message|queues messages'`
- `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx` (52 passed)
- `pnpm -r typecheck`
- `pnpm test:run` (all server and UI groups passed; the CLI group passed
after inherited static AWS credential variables were omitted)
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts`
- `pnpm build`
- `pnpm check:token-gates`

## Risks

Low risk. The change only adds an action to queued messages that have a
target run and an interrupt callback. Messages without both values keep
the current rendering.

> 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 Codex with GPT-5. The runtime does not expose a more specific
deployment ID or context-window size. The model used reasoning,
repository tools, code execution, and 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 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-08-14 14:26:18 -04:00 committed by GitHub
parent 66515582e4
commit 8cb0ce0de5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 224 additions and 7 deletions

View File

@ -96,6 +96,76 @@ describe("TaskChatThread composer alignment (PAP-498)", () => {
});
});
describe("TaskChatThread queued message actions", () => {
it("interrupts the exact run that a persisted queued message is waiting behind", () => {
const onInterruptQueued = vi.fn(async () => {});
const queuedComment = {
id: "comment-queued",
companyId: "company-1",
issueId: "issue-1",
authorType: "user" as const,
authorAgentId: null,
authorUserId: "user-1",
body: "Use the latest requirements instead.",
presentation: null,
metadata: null,
queueState: "queued" as const,
queueTargetRunId: "run-active",
createdAt: new Date("2026-08-14T12:00:00.000Z"),
updatedAt: new Date("2026-08-14T12:00:00.000Z"),
};
render(
<TaskChatThread
comments={[queuedComment]}
onAdd={async () => {}}
onInterruptQueued={onInterruptQueued}
/>,
);
const interrupt = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Interrupt",
);
expect(container.textContent).toContain("Queued");
expect(interrupt).not.toBeUndefined();
flushSync(() => interrupt!.click());
expect(onInterruptQueued).toHaveBeenCalledOnce();
expect(onInterruptQueued).toHaveBeenCalledWith("run-active");
});
it("disables the action while the queued run is being interrupted", () => {
render(
<TaskChatThread
comments={[{
id: "comment-queued",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Use the latest requirements instead.",
presentation: null,
metadata: null,
clientStatus: "queued",
queueTargetRunId: "run-active",
createdAt: new Date("2026-08-14T12:00:00.000Z"),
updatedAt: new Date("2026-08-14T12:00:00.000Z"),
}]}
onAdd={async () => {}}
onInterruptQueued={async () => {}}
interruptingQueuedRunId="run-active"
/>,
);
const interrupting = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Interrupting…",
);
expect(interrupting).not.toBeUndefined();
expect(interrupting?.disabled).toBe(true);
});
});
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
sidebarState.isMobile = true;

View File

@ -41,6 +41,7 @@ import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
@ -123,6 +124,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
feedbackTermsUrl = null,
onVote,
draftKey,
onInterruptQueued,
interruptingQueuedRunId,
} = props;
const linkedRunMetaById = useMemo(() => {
@ -522,6 +525,27 @@ export function TaskChatThread(props: TaskChatThreadProps) {
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
);
const renderQueuedAction = useCallback(
(item: TaskChatMessageItem) => {
const runId = item.queueTargetRunId;
if (item.optimistic !== "queued" || !runId || !onInterruptQueued) return null;
const isInterrupting = interruptingQueuedRunId === runId;
return (
<Button
type="button"
variant="link"
className="h-auto p-0 text-(length:--text-micro)"
disabled={isInterrupting}
onClick={() => void onInterruptQueued(runId)}
>
{isInterrupting ? "Interrupting…" : "Interrupt"}
</Button>
);
},
[interruptingQueuedRunId, onInterruptQueued],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
@ -585,6 +609,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
tail={tailRunId ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill

View File

@ -20,6 +20,8 @@ import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
item: TaskChatMessageItem;
/** Action shown beside the queued state for an interruptible message. */
queuedAction?: ReactNode;
/**
* The settled run turn rendered on this bubble's footer line (round 9):
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
@ -61,7 +63,7 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
};
}
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) {
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@ -171,8 +173,9 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
</AttachmentGroup>
) : null}
{item.optimistic ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.optimistic === "queued" ? "Queued" : "Sending…"}
<span className="flex items-center gap-1 px-1 text-(length:--text-micro) text-muted-foreground">
<span>{item.optimistic === "queued" ? "Queued" : "Sending…"}</span>
{item.optimistic === "queued" ? queuedAction : null}
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"

View File

@ -41,6 +41,8 @@ interface TaskChatThreadViewProps {
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
/** Renders an interrupt action beside a queued human message. */
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode;
/** Content appended inside the transcript scroller after the settled thread. */
tail?: ReactNode;
/** Optional streaming-aware key when `tail` changes without changing `items`. */
@ -56,6 +58,7 @@ function renderItem(
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode,
) {
switch (item.kind) {
case "message": {
@ -69,6 +72,7 @@ function renderItem(
<TaskChatBubble
item={item}
actions={actions}
queuedAction={renderQueuedAction?.(item)}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
@ -135,6 +139,7 @@ export function TaskChatThreadView({
renderInteraction,
renderBrief,
renderMessageActions,
renderQueuedAction,
tail,
contentKey,
className,
@ -149,7 +154,14 @@ export function TaskChatThreadView({
) : null}
{items.map((item) => (
<div key={item.id}>
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
{renderItem(
item,
onApprovalDecision,
renderInteraction,
renderBrief,
renderMessageActions,
renderQueuedAction,
)}
</div>
))}
{tail}

View File

@ -70,8 +70,9 @@ export function commentsToTaskChatItems(
authorName =
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
}
const queued = comment.queueState === "queued" || comment.clientStatus === "queued";
const optimistic =
comment.clientStatus === "queued"
queued
? "queued"
: comment.clientStatus === "pending"
? "pending"
@ -90,6 +91,7 @@ export function commentsToTaskChatItems(
text: comment.body,
timestamp: formatTaskChatTimestamp(comment.createdAt),
optimistic,
queueTargetRunId: queued ? comment.queueTargetRunId ?? null : null,
agentIcon,
onBehalfOfUserName,
// System notices carry their structured hints through to the render

View File

@ -70,6 +70,8 @@ export interface TaskChatMessageItem {
streaming?: boolean;
/** Optimistic local echo state (matches IssueChatComment.clientStatus). */
optimistic?: "pending" | "queued";
/** Live run this queued message is waiting behind. */
queueTargetRunId?: string | null;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
/**

View File

@ -1760,6 +1760,102 @@ describe("IssueDetail", () => {
mockHeartbeatsApi.cancel.mockClear();
});
it("does not rebind a queued message when another run becomes live before its request settles", async () => {
const postedComment = createDeferred<IssueComment>();
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
executionRunId: "run-original",
}));
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
mockHeartbeatsApi.cancel.mockResolvedValue({});
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-original",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:01.000Z",
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 initialProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
};
await act(async () => {
void initialProps.onAdd("Keep this bound to the original run");
await Promise.resolve();
});
await flushReact();
const replacementRun = {
id: "run-replacement",
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:02.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:02.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
};
await act(async () => {
queryClient.setQueryData(queryKeys.issues.liveRuns("issue-1"), [replacementRun]);
queryClient.setQueryData(queryKeys.issues.activeRun("issue-1"), replacementRun);
});
await flushReact();
const replacementProps = 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 = replacementProps.comments?.find(
(comment) => comment.body === "Keep this bound to the original run",
);
expect(optimisticComment).toMatchObject({
clientStatus: "queued",
queueTargetRunId: "run-original",
});
await act(async () => {
await replacementProps.onInterruptQueued(optimisticComment!.queueTargetRunId!);
});
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-original");
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalledWith("run-replacement");
await act(async () => {
postedComment.resolve(createIssueComment({ body: "Keep this bound to the original run" }));
});
await flushReact();
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

@ -1226,7 +1226,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
if (followUpCommentIds.has(comment.id)) {
nextComment.followUpRequested = true;
}
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
const queuedTargetRunId =
locallyQueuedCommentRunIds.get(comment.id) ?? nextComment.queueTargetRunId ?? null;
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
queuedTargetRunId,
targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false,
@ -1235,6 +1236,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
if (locallyQueuedComment !== nextComment) {
return locallyQueuedComment;
}
// A queued target is fixed when the message is submitted. If that run
// settles while the request is still in flight, do not rebind the
// message's Interrupt action to an unrelated run that became live later.
if (queuedTargetRunId) {
return nextComment;
}
if (
isQueuedIssueComment({
comment: nextComment,
@ -1249,7 +1256,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
return {
...nextComment,
queueState: "queued" as const,
queueTargetRunId: interruptibleIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
queueTargetRunId: interruptibleIssueRun?.id ?? null,
queueReason: queuedCommentReason,
};
}