Files
PaperClipAI/ui/src/components/task-chat/TaskChatQueuedMessages.tsx
T
Devin FoleyandPaperclip c9213d1be4 fix: send saved queued messages after Stop (#17959)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People can save follow-up messages while an agent runs.
> - Stop preserves those messages and stops the active execution.
> - The queue removed its delivery controls when that execution ended.
> - Native admission also refused an explicit resend of older queued
input.
> - This change adds Send for a stopped queue and records fresh board
intent.
> - The saved message can continue through normal cleanup and admission
checks.

## Linked Issues or Issue Description

**What happened?**

A person queued a message with a screenshot while an agent was starting.
Steering was not yet available. They stopped the run. The message
remained visible, but no control could send it. Native queue delivery
also rejected this transition.

**Expected behavior**

A stopped queue offers Send. The click preserves message IDs, content,
attachments and original authors. It starts at most one eligible
continuation. Stop remains effective until the person gives new
direction.

**Steps to reproduce**

1. Start a native task and queue a second message.
2. Stop the active run before it consumes that message.
3. Open the task after cleanup. Observe the saved message with no send
control.

Refs #16965 for the queue-control change and #15015 for stopped
conversation recovery. No open repair for this exact transition was
found.

## What Changed

- Show Send for a saved deferred queue with no active run.
- Accept the existing board queue action for a stopped native target.
Keep live native interruption restricted.
- Use the server-recorded click time to authorize older native messages.
Reject stale, malformed and wrong-actor intents.
- Reuse normal queue dispatch, revision checks, cleanup proofs and
execution admission, including a failed native predecessor after a
verified Send.
- Ignore a fully committed, successful recovered export when admitting a
fresh message, while retaining the wait for results still committing.
- Honor an applied `/new` conversation boundary while retaining native
execution holds until the existing stop and cleanup checks verify them.
Workspace holds also survive reset. Check the newest failed or cancelled
native execution even when older recovery actions exist.
- Keep native automatic delivery separate from the explicit Send action.
Update execution and access documentation.

## Verification

- Meaningful regressions failed before the fixes: stopped queues lacked
Send, native admission refused fresh intent, cleanup and dispatch
receipts were missing, deferred rows disappeared, live plan Interrupt
rejected its own receipt, and Agent Chat `/new` inherited an old
non-workspace execution hold.
- Six assembled-wakeup cases fail before the reset cleanup fix. All 11
reset/history cases pass after it: live processes, missing identity,
quarantined cleanup, remote leases and live controllers remain held;
verified cleanup proceeds and retires the hold. Two more cases fail
before the fallback fix and pass after it: Send verifies a stopped or
live native predecessor despite a later reset receipt and no recovery
record.
- **525 server tests pass** across seven queue admission, route,
dispatch, envelope, provenance, blocker and conversation files. **343
queue/thread UI tests** also pass. Server/UI TypeScript, token and map
guards pass. The final scoped server typecheck also builds native Runner
successfully.
- The latest regressions prove that completed recovered exports permit
fresh input, and clearing conversation history still requires old native
stop and cleanup verification; an unsafe workspace still blocks after
`/new`; failed native Send reaches actual continuation dispatch with the
original queued comment. A result still being committed remains
deferred. History plus a newer Stop cannot hide a missing
process/controller/lease receipt.
- The real Chromium Stop/Send journey passes on current head
`75fa5e7222c59f43ff7ac326e81093bc9da28330`: queue a follow-up, Stop,
reload, explicitly Send, then verify exactly one useful response and no
live run. Earlier browser journeys cover legacy Interrupt, interruption
during a write and Stop followed by a new direction. These use an
isolated app/database and controlled ACP fixture, not an external AI
account or a staging deployment.
- Negative cases retain actor, company, issue, assignee, queue and
cancellation-receipt bindings; cleanup/process/workspace/controller
holds; approvals; pause and budget gates. Typed responses keep their
original resolver and immutable result.
- Full local recursive typecheck stopped in unchanged Rust Runner
compilation with ENOSPC, so the chained full tests/build did not run.
All hosted CI gates pass on final commit
`75fa5e7222c59f43ff7ac326e81093bc9da28330`, including typecheck, tests,
build and E2E. Apex review on that commit is 5/5, with no unresolved
findings. Earlier unrelated CI failures involved PostgreSQL
fixture-cleanup deadlock and an unchanged decision-expiry deadline
assertion; the latter's 20 cases pass locally without source changes. No
assertions or limits were weakened. Local resource cleanup was limited
to this task's verified completed test processes and rebuildable npm
caches.

## Risks

- Send can authorize a new turn for a saved message after Stop. A Send
click must be newer than the stopped execution; a live plan Interrupt is
bound to the cancellation receipt produced by that same click. It does
not imply that cleanup finished or that an external action is safe to
replay.
- The existing endpoint and intent format are reused. No schema or
credential change is required. Live native steering and legacy
interruption keep their existing paths.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository inspection, tool
use and code execution. The session does not expose the exact serving
model ID or context window size.

## 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>
2026-10-27 20:34:14 -07:00

457 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CornerDownRight,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Trash2,
} from "lucide-react";
import type {
IssueQueuedCommentEntry,
IssueQueuedCommentQueue,
} from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { TaskChatComposerBar } from "./TaskChatComposerBar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type QueueAction = "steer" | "interrupt" | "discard" | null;
export function reorderQueuedMessageEntries(
entries: IssueQueuedCommentEntry[],
activeId: string,
overId: string,
) {
if (entries.some((entry) => entry.source?.kind === "interaction")) return null;
const from = entries.findIndex((entry) => entry.comment.id === activeId);
const to = entries.findIndex((entry) => entry.comment.id === overId);
if (from < 0 || to < 0 || from === to) return null;
return arrayMove(entries, from, to).map((entry, position) => ({
...entry,
position,
}));
}
function queueActionErrorCode(error: unknown): string | null {
if (typeof error !== "object" || error === null) return null;
const body = (error as { body?: unknown }).body;
if (typeof body !== "object" || body === null) return null;
const directCode = (body as { code?: unknown }).code;
if (typeof directCode === "string") return directCode;
const details = (body as { details?: unknown }).details;
if (typeof details !== "object" || details === null) return null;
const detailCode = (details as { code?: unknown }).code;
return typeof detailCode === "string" ? detailCode : null;
}
export interface TaskChatQueuedMessagesProps {
queue: IssueQueuedCommentQueue;
onEdit: (commentId: string) => void;
onReorder: (orderedCommentIds: string[], revision: string) => Promise<void>;
onSteer: (commentId: string, revision: string) => Promise<void>;
onInterrupt?: () => Promise<IssueQueuedCommentQueue | void>;
onDiscard: (commentId: string, revision: string) => Promise<void>;
}
function SortableQueuedMessage({
entry,
queue,
busy,
queueMutationDisabled,
action,
onEdit,
onSteer,
onInterrupt,
onDiscard,
}: {
entry: IssueQueuedCommentEntry;
queue: IssueQueuedCommentQueue;
busy: boolean;
queueMutationDisabled: boolean;
action: QueueAction;
onEdit: () => void;
onSteer: () => void;
onInterrupt?: () => void;
onDiscard: () => void;
}) {
const immutableResponse = entry.source?.kind === "interaction";
const label = immutableResponse ? entry.comment.body.split("\n")[0] : entry.comment.body;
const sortable = useSortable({
id: entry.comment.id,
disabled: queueMutationDisabled || immutableResponse,
});
const style = {
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
};
const steerDisabled =
queueMutationDisabled || queue.steeringDisposition !== "available";
const steerTitle =
queue.steeringDisposition === "unsupported"
? "This runner does not support steering"
: queue.steeringDisposition === "temporarily_unavailable"
? "Steering is temporarily unavailable"
: "Steer this message into the active turn";
return (
<div
ref={sortable.setNodeRef}
style={style}
className={cn(
"group flex h-11 min-w-0 items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 text-sm last:border-b-0",
sortable.isDragging &&
"relative z-20 rounded-lg border border-border shadow-lg",
)}
data-testid={`task-chat-queued-message-${entry.comment.id}`}
>
<button
type="button"
ref={sortable.setActivatorNodeRef}
{...sortable.attributes}
{...sortable.listeners}
disabled={queueMutationDisabled || immutableResponse}
aria-label={`Reorder queued message: ${entry.comment.body}`}
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</button>
<CornerDownRight
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate px-1" title={entry.comment.body}>
{label}
</span>
{(queue.targetRunId || queue.state === "deferred") && (!queue.targetRunId || queue.protocol === "legacy" || entry.source?.requiresFreshSession ? (
<button
type="button"
onClick={onInterrupt}
aria-label={!queue.targetRunId ? "Send queued messages" : undefined}
disabled={busy || !queue.queueId || !onInterrupt}
title={queue.targetRunId ? "Interrupt the active turn and send queued messages" : "Send queued messages now"}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
data-testid={`task-chat-queued-interrupt-${entry.comment.id}`}
>
{action === "interrupt" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
)}
{queue.targetRunId ? "Interrupt" : "Send"}
</button>
) : (
<button
type="button"
onClick={onSteer}
disabled={steerDisabled}
title={steerTitle}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
data-testid={`task-chat-queued-steer-${entry.comment.id}`}
>
{action === "steer" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
)}
Steer
</button>
))}
<button
type="button"
onClick={onDiscard}
disabled={
busy ||
(!queue.queueId && !entry.comment.id.startsWith("optimistic-")) ||
!entry.canDiscard
}
title="Discard queued message"
aria-label={`Discard queued message: ${entry.comment.body}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
data-testid={`task-chat-queued-discard-${entry.comment.id}`}
>
{action === "discard" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Trash2 className="h-3.5 w-3.5" aria-hidden />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={queueMutationDisabled || immutableResponse}
title="Queued message actions"
aria-label={`Queued message actions: ${entry.comment.body}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem disabled={!entry.canEdit} onSelect={onEdit}>
<Pencil className="h-4 w-4" aria-hidden />
Edit message
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/** Compact production queue shown immediately above the default task composer. */
export function TaskChatQueuedMessages({
queue,
onEdit,
onReorder,
onSteer,
onInterrupt,
onDiscard,
}: TaskChatQueuedMessagesProps) {
const [entries, setEntries] = useState(queue.entries);
const latestQueue = useRef(queue);
latestQueue.current = queue;
const optimisticDeliveryIds = useRef(new Set<string>());
const [pending, setPending] = useState<{
commentId: string;
action: Exclude<QueueAction, null>;
} | null>(null);
const [reordering, setReordering] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [visibleError, setVisibleError] = useState<string | null>(null);
const [deferredStatus, setDeferredStatus] = useState<{ queueId: string | null; message: string } | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
useEffect(() => {
// Polling can return the old queue while delivery is still in flight.
setEntries(queue.entries.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}, [queue.entries, queue.revision]);
useEffect(() => {
setDeferredStatus(current => {
if (!current) return null;
if (queue.state !== "deferred" || queue.queueId !== current.queueId || !queue.entries.length) return null;
const message = queue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready.";
return current.message === message ? current : { ...current, message };
});
}, [queue.state, queue.queueId, queue.entries.length, queue.executionWait?.message]);
const ids = useMemo(
() => entries.map((entry) => entry.comment.id),
[entries],
);
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (
!queue.queueId ||
!over ||
active.id === over.id ||
reordering ||
pending
)
return;
const previous = entries;
const next = reorderQueuedMessageEntries(
entries,
String(active.id),
String(over.id),
);
if (!next) return;
const orderedIds = next.map((entry) => entry.comment.id);
const activeCommentId = String(active.id);
const to = next.findIndex((entry) => entry.comment.id === activeCommentId);
setEntries(next);
setReordering(true);
setVisibleError(null);
setAnnouncement(
`Moved queued message to position ${to + 1} of ${next.length}.`,
);
try {
await onReorder(orderedIds, queue.revision);
} catch (error) {
setEntries(previous);
setAnnouncement("");
setVisibleError(
queueActionErrorCode(error) === "queued_comment_revision_conflict"
? "The queue changed in another session. Its latest order has been restored."
: "Couldn’t reorder. Previous order restored.",
);
} finally {
setReordering(false);
}
}
async function runRowAction(
commentId: string,
action: Exclude<QueueAction, null>,
) {
const locallyDiscardable =
action === "discard" && commentId.startsWith("optimistic-");
if (
pending ||
reordering ||
(!queue.queueId && action !== "interrupt" && !locallyDiscardable)
) {
return;
}
const deliveredIds = action === "interrupt"
? entries.map((entry) => entry.comment.id)
: action === "steer" ? [commentId] : [];
for (const id of deliveredIds) optimisticDeliveryIds.current.add(id);
if (deliveredIds.length) {
setEntries((current) => current.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}
setPending({ commentId, action });
setVisibleError(null); setDeferredStatus(null);
setAnnouncement(
action === "steer"
? "Steering queued message."
: action === "interrupt"
? "Sending queued messages."
: "Discarding queued message.",
);
try {
if (action === "steer") await onSteer(commentId, queue.revision);
else if (action === "interrupt") {
const updatedQueue = await onInterrupt?.();
if (updatedQueue?.state === "deferred") {
for (const entry of updatedQueue.entries) optimisticDeliveryIds.current.delete(entry.comment.id);
setEntries(updatedQueue.entries.filter(entry => !optimisticDeliveryIds.current.has(entry.comment.id)));
setDeferredStatus({ queueId: updatedQueue.queueId, message: updatedQueue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready." });
setAnnouncement("Messages are still queued.");
return;
}
}
else await onDiscard(commentId, queue.revision);
if (action === "discard") {
setEntries((current) =>
current.filter((entry) => entry.comment.id !== commentId),
);
}
setAnnouncement(
action === "steer"
? "Message steered into the active turn."
: action === "interrupt"
? queue.targetRunId
? "Queued messages will be sent when the previous run has stopped."
: "Queued messages submitted. Waiting for the agent."
: "Queued message discarded.",
);
} catch (error) {
for (const id of deliveredIds) optimisticDeliveryIds.current.delete(id);
if (deliveredIds.length) {
setEntries(latestQueue.current.entries.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}
setAnnouncement("");
const code = queueActionErrorCode(error);
setVisibleError(
code === "queued_comment_already_dispatching"
? "Too late to discard: this message is already being sent."
: action === "steer"
? "Couldn’t steer. Message is still queued."
: action === "interrupt"
? queue.targetRunId ? "Couldn’t interrupt. Message is still queued." : "Couldn’t send. Message is still queued."
: code === "queued_comment_revision_conflict"
? "The queue changed in another session. Review it and try again."
: "Couldn’t discard. Message is still queued.",
);
} finally {
setPending(null);
}
}
if (entries.length === 0 && !visibleError && !deferredStatus) return null;
return (
<TaskChatComposerBar
data-testid="task-chat-queued-messages"
aria-label="Queued messages"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDragEnd(event)}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{entries.map((entry) => {
const action =
pending?.commentId === entry.comment.id ? pending.action : null;
const busy = Boolean(pending || reordering);
const queueMutationDisabled = Boolean(
!queue.queueId || pending || reordering,
);
return (
<SortableQueuedMessage
key={entry.comment.id}
entry={entry}
queue={queue}
busy={busy}
queueMutationDisabled={queueMutationDisabled}
action={action}
onEdit={() => onEdit(entry.comment.id)}
onSteer={() => void runRowAction(entry.comment.id, "steer")}
onInterrupt={
onInterrupt
? () => void runRowAction(entry.comment.id, "interrupt")
: undefined
}
onDiscard={() => void runRowAction(entry.comment.id, "discard")}
/>
);
})}
</SortableContext>
</DndContext>
{deferredStatus ? <p role="status" className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">{deferredStatus.message}</p> : null}
{visibleError ? (
<div
role="status"
aria-live="polite"
className="border-t border-destructive/20 bg-destructive/5 px-3 py-1.5 text-xs text-destructive"
>
{visibleError}
</div>
) : null}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
</TaskChatComposerBar>
);
}