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:
parent
66515582e4
commit
8cb0ce0de5
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue