import { useEffect, useMemo, 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; type QueueAction = "steer" | "interrupt" | "discard" | null; export function reorderQueuedMessageEntries( entries: IssueQueuedCommentEntry[], activeId: string, overId: string, ) { 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; onSteer: (commentId: string, revision: string) => Promise; onInterrupt?: () => Promise; onDiscard: (commentId: string, revision: string) => Promise; } 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 sortable = useSortable({ id: entry.comment.id, disabled: queueMutationDisabled, }); 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 (
{entry.comment.body} {queue.protocol === "legacy" ? ( ) : ( )} Edit message
); } /** 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 [pending, setPending] = useState<{ commentId: string; action: Exclude; } | null>(null); const [reordering, setReordering] = useState(false); const [announcement, setAnnouncement] = useState(""); const [visibleError, setVisibleError] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); useEffect(() => { setEntries(queue.entries); }, [queue.entries, queue.revision]); 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, ) { const locallyDiscardable = action === "discard" && commentId.startsWith("optimistic-"); if ( pending || reordering || (!queue.queueId && action !== "interrupt" && !locallyDiscardable) ) { return; } const previous = entries; setPending({ commentId, action }); setVisibleError(null); setAnnouncement( action === "steer" ? "Steering queued message." : action === "interrupt" ? "Interrupting the active turn." : "Discarding queued message.", ); if (action === "steer") { setEntries((current) => current.filter((entry) => entry.comment.id !== commentId), ); } try { if (action === "steer") await onSteer(commentId, queue.revision); else if (action === "interrupt") await onInterrupt?.(); 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" ? "Active turn interrupted. Message remains queued." : "Queued message discarded.", ); } catch (error) { if (action === "steer") setEntries(previous); 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" ? "Couldn’t interrupt. 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) return null; return (
void handleDragEnd(event)} > {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 ( 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")} /> ); })} {visibleError ? (
{visibleError}
) : null}
{announcement}
); }