import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; import type { ChatAttachment as ContractChatAttachment, EnvironmentId, MessageId, RunId, ThreadId, } from "@t3tools/contracts"; import { Clock3Icon, CornerUpRightIcon, GripVerticalIcon, ListOrderedIcon, PencilIcon, } from "lucide-react"; import { useId, useMemo, useRef, useState } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { threadEnvironment } from "../../state/threads"; import { useThreadProjection } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { isImageAttachment, type ChatMessage } from "../../types"; import { cn } from "~/lib/utils"; import { ComposerBanner } from "./ComposerBanner"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface EditQueuedRunRequest { readonly runId: RunId; readonly messageId: MessageId; readonly text: string; readonly attachments: ReadonlyArray; } interface QueuedRowThumbnail { readonly key: string; readonly name: string; readonly url: string | null; } const QUEUED_RUN_DRAG_TYPE = "application/x-t3code-queued-run"; export function QueuedRunsControl(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly optimisticMessages: ReadonlyArray< Pick >; /** The saved queue entry stays visible while its draft is edited in the composer. */ readonly editingRunId: RunId | null; readonly onEditQueuedRun: (request: EditQueuedRunRequest) => void; readonly onCancelEdit: () => void; }) { const projection = useThreadProjection( scopeThreadRef(props.environmentId, props.threadId), )?.projection; const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun); const promote = useAtomCommand(threadEnvironment.promoteQueuedRun); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun); const [expanded, setExpanded] = useState(true); const queueListId = useId(); const [busyRunId, setBusyRunId] = useState(null); // Live drag-reorder state: the dragged run and the queue index the row // would be inserted at (0..queued.length) as of the latest dragover. const [dragState, setDragState] = useState<{ readonly runId: RunId; readonly insertIndex: number | null; } | null>(null); // Drags must start from the grip, not from an accidental text-drag on the // row. The grip arms its run id on pointerdown; dragstart verifies it. const dragArmedRunIdRef = useRef(null); const workflow = useMemo( () => (projection ? deriveThreadQueueWorkflowState(projection) : null), [projection], ); const queued = workflow?.queuedRuns ?? []; const activeRun = workflow?.activeRun ?? null; const canReorder = workflow?.canReorder === true; const queuedImageAttachmentIds = useMemo(() => { const ids: string[] = []; for (const { attachments } of workflow?.queuedRuns ?? []) { for (const attachment of attachments) { if (attachment.type === "image") ids.push(attachment.id); } } return ids; }, [workflow]); const queuedImageAttachmentResources = useMemo( () => queuedImageAttachmentIds.map((attachmentId) => ({ _tag: "attachment" as const, attachmentId, })), [queuedImageAttachmentIds], ); const queuedImageAttachmentUrls = useAssetUrls( props.environmentId, queuedImageAttachmentResources, ); const queuedImageUrlById = useMemo( () => new Map( queuedImageAttachmentIds.flatMap((attachmentId, index) => { const url = queuedImageAttachmentUrls[index]; return url ? [[attachmentId, url] as const] : []; }), ), [queuedImageAttachmentIds, queuedImageAttachmentUrls], ); // Once the projection holds the message the optimistic copy is stale no // matter what happened to its run — keying on still-queued runs alone would // resurrect a phantom "pending" row after the run is cancelled or started. const acknowledgedMessageIds = useMemo( () => new Set((projection?.messages ?? []).map((message) => message.id)), [projection], ); const optimisticQueued = props.optimisticMessages.filter( (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id), ); const items = [ ...queued.map(({ run, text, attachments }, serverIndex) => ({ key: run.id, runId: run.id, messageId: run.userMessageId, serverIndex, text, attachments, thumbnails: attachments .filter((attachment) => attachment.type === "image") .map((attachment) => ({ key: attachment.id, name: attachment.name, url: queuedImageUrlById.get(attachment.id) ?? null, })), pending: false, })), ...optimisticQueued.map((message) => ({ key: message.id, runId: null, messageId: null, serverIndex: null, text: message.text, attachments: [] as ReadonlyArray, thumbnails: (message.attachments ?? []) .filter(isImageAttachment) .map((attachment) => ({ key: attachment.id, name: attachment.name, url: attachment.previewUrl ?? null, })), pending: true, })), ]; if (items.length === 0) return null; const move = async (runId: RunId, beforeRunId: RunId | null) => { setBusyRunId(runId); try { await reorder({ environmentId: props.environmentId, input: { threadId: props.threadId, runId, beforeRunId }, }); } finally { setBusyRunId(null); } }; const completeDrag = (runId: RunId, insertIndex: number | null) => { setDragState(null); dragArmedRunIdRef.current = null; if (insertIndex === null || busyRunId !== null) return; const draggedIndex = queued.findIndex(({ run }) => run.id === runId); if (draggedIndex === -1) return; // Inserting immediately before or after itself is a no-op. if (insertIndex === draggedIndex || insertIndex === draggedIndex + 1) return; void move(runId, queued[insertIndex]?.run.id ?? null); }; const steer = async (queuedRunId: RunId) => { if (activeRun === null) return; setBusyRunId(queuedRunId); try { await promote({ environmentId: props.environmentId, input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id }, }); } finally { setBusyRunId(null); } }; const remove = async (runId: RunId) => { setBusyRunId(runId); try { await cancel({ environmentId: props.environmentId, input: { threadId: props.threadId, runId }, }); } finally { setBusyRunId(null); } }; return ( } aria-label={expanded ? "Collapse queued messages" : "Expand queued messages"} aria-expanded={expanded} aria-controls={queueListId} onPointerDown={(event) => event.preventDefault()} onClick={() => setExpanded((value) => !value)} > Queued {items.length} } id={queueListId}> {items.map((item) => { const rowRunId = item.runId; const rowServerIndex = item.serverIndex; const isEditing = rowRunId !== null && rowRunId === props.editingRunId; const rowDraggable = rowRunId !== null && rowServerIndex !== null && canReorder && busyRunId === null; return ( } key={item.key} aria-current={isEditing ? "true" : undefined} className={cn( "relative rounded-sm", isEditing && "bg-accent text-accent-foreground", dragState !== null && dragState.runId === item.runId && "opacity-50", )} draggable={rowDraggable} onDragStart={(event) => { if (item.runId === null || dragArmedRunIdRef.current !== item.runId) { event.preventDefault(); return; } event.dataTransfer.setData(QUEUED_RUN_DRAG_TYPE, item.runId); event.dataTransfer.effectAllowed = "move"; setDragState({ runId: item.runId, insertIndex: null }); }} onDragEnd={() => { dragArmedRunIdRef.current = null; setDragState(null); }} onDragOver={(event) => { if (dragState === null || item.serverIndex === null) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; const rect = event.currentTarget.getBoundingClientRect(); const insertIndex = event.clientY < rect.top + rect.height / 2 ? item.serverIndex : item.serverIndex + 1; if (dragState.insertIndex !== insertIndex) { setDragState({ runId: dragState.runId, insertIndex }); } }} onDrop={(event) => { if (dragState === null) return; event.preventDefault(); completeDrag(dragState.runId, dragState.insertIndex); }} > {item.serverIndex !== null && dragState?.insertIndex === item.serverIndex ? ( ) : null} {item.serverIndex === queued.length - 1 && dragState?.insertIndex === queued.length ? ( ) : null} {canReorder && rowRunId !== null && rowServerIndex !== null ? ( ) : null} {isEditing ? Editing queued message: : null} {item.pending ? ( ) : null} {item.thumbnails.length > 0 ? ( {item.thumbnails.map((thumbnail) => ( {thumbnail.url ? ( {thumbnail.name} ) : ( )} ))} ) : null} }> {item.text} {item.text} {isEditing ? ( ) : ( <> { if (item.runId !== null && item.messageId !== null) { props.onEditQueuedRun({ runId: item.runId, messageId: item.messageId, text: item.text, attachments: item.attachments, }); } }} /> } > Edit in the composer }> {activeRun === null ? "There is no active run to steer" : "Send as a steer instead"} { if (item.runId !== null) void remove(item.runId); }} /> } /> Remove from queue )} ); })} ); }