1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
3import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
4import type {
5 ChatAttachment as ContractChatAttachment,
6 EnvironmentId,
7 MessageId,
8 RunId,
9 ThreadId,
10} from "@t3tools/contracts";
11import {
12 Clock3Icon,
13 CornerUpRightIcon,
14 GripVerticalIcon,
15 ListOrderedIcon,
16 PencilIcon,
17} from "lucide-react";
18import { useId, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react";
19
20import { useAssetUrls } from "../../assets/assetUrls";
21import { threadEnvironment } from "../../state/threads";
22import { useThreadProjection } from "../../state/entities";
23import { useAtomCommand } from "../../state/use-atom-command";
24import { isImageAttachment, type ChatMessage } from "../../types";
25import { cn } from "~/lib/utils";
26import { ComposerBanner } from "./ComposerBanner";
27import { Button } from "../ui/button";
28import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
29
30export interface EditQueuedRunRequest {
31 readonly runId: RunId;
32 readonly messageId: MessageId;
33 readonly text: string;
34 readonly attachments: ReadonlyArray<ContractChatAttachment>;
35}
36
37interface QueuedRowThumbnail {
38 readonly key: string;
39 readonly name: string;
40 readonly url: string | null;
41}
42
43const QUEUED_RUN_DRAG_TYPE = "application/x-t3code-queued-run";
44
45export interface QueuedRunsControlHandle {
46 steerNext: (repeat: boolean) => boolean;
47 editLatest: (repeat: boolean) => boolean;
48}
49
50export function QueuedRunsControl({
51 ref,
52 ...props
53}: {
54 readonly ref?: Ref<QueuedRunsControlHandle>;
55 readonly steerShortcutLabel?: string | null;
56 readonly editShortcutLabel?: string | null;
57 readonly environmentId: EnvironmentId;
58 readonly threadId: ThreadId;
59 readonly optimisticMessages: ReadonlyArray<
60 Pick<ChatMessage, "id" | "inputIntent" | "text" | "attachments">
61 >;
62 /** The saved queue entry stays visible while its draft is edited in the composer. */
63 readonly editingRunId: RunId | null;
64 readonly onEditQueuedRun: (request: EditQueuedRunRequest) => void;
65 readonly onCancelEdit: () => void;
66}) {
67 const projection = useThreadProjection(
68 scopeThreadRef(props.environmentId, props.threadId),
69 )?.projection;
70 const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);
71 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
72 const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun);
73 const [expanded, setExpanded] = useState(true);
74 const queueListId = useId();
75 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
76 // Live drag-reorder state: the dragged run and the queue index the row
77 // would be inserted at (0..queued.length) as of the latest dragover.
78 const [dragState, setDragState] = useState<{
79 readonly runId: RunId;
80 readonly insertIndex: number | null;
81 } | null>(null);
82 // Drags must start from the grip, not from an accidental text-drag on the
83 // row. The grip arms its run id on pointerdown; dragstart verifies it.
84 const dragArmedRunIdRef = useRef<RunId | null>(null);
85 const workflow = useMemo(
86 () => (projection ? deriveThreadQueueWorkflowState(projection) : null),
87 [projection],
88 );
89 const queued = workflow?.queuedRuns ?? [];
90 const activeRun = workflow?.activeRun ?? null;
91 const canReorder = workflow?.canReorder === true;
92 const queuedImageAttachmentIds = useMemo(() => {
93 const ids: string[] = [];
94 for (const { attachments } of workflow?.queuedRuns ?? []) {
95 for (const attachment of attachments) {
96 if (attachment.type === "image") ids.push(attachment.id);
97 }
98 }
99 return ids;
100 }, [workflow]);
101 const queuedImageAttachmentResources = useMemo(
102 () =>
103 queuedImageAttachmentIds.map((attachmentId) => ({
104 _tag: "attachment" as const,
105 attachmentId,
106 })),
107 [queuedImageAttachmentIds],
108 );
109 const queuedImageAttachmentUrls = useAssetUrls(
110 props.environmentId,
111 queuedImageAttachmentResources,
112 );
113 const queuedImageUrlById = useMemo(
114 () =>
115 new Map(
116 queuedImageAttachmentIds.flatMap((attachmentId, index) => {
117 const url = queuedImageAttachmentUrls[index];
118 return url ? [[attachmentId, url] as const] : [];
119 }),
120 ),
121 [queuedImageAttachmentIds, queuedImageAttachmentUrls],
122 );
123 // Once the projection holds the message the optimistic copy is stale no
124 // matter what happened to its run — keying on still-queued runs alone would
125 // resurrect a phantom "pending" row after the run is cancelled or started.
126 const acknowledgedMessageIds = useMemo(
127 () => new Set((projection?.messages ?? []).map((message) => message.id)),
128 [projection],
129 );
130 const optimisticQueued = props.optimisticMessages.filter(
131 (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id),
132 );
133 const items = [
134 ...queued.map(({ run, text, attachments }, serverIndex) => ({
135 key: run.id,
136 runId: run.id,
137 messageId: run.userMessageId,
138 serverIndex,
139 text,
140 attachments,
141 thumbnails: attachments
142 .filter((attachment) => attachment.type === "image")
143 .map<QueuedRowThumbnail>((attachment) => ({
144 key: attachment.id,
145 name: attachment.name,
146 url: queuedImageUrlById.get(attachment.id) ?? null,
147 })),
148 pending: false,
149 })),
150 ...optimisticQueued.map((message) => ({
151 key: message.id,
152 runId: null,
153 messageId: null,
154 serverIndex: null,
155 text: message.text,
156 attachments: [] as ReadonlyArray<ContractChatAttachment>,
157 thumbnails: (message.attachments ?? [])
158 .filter(isImageAttachment)
159 .map<QueuedRowThumbnail>((attachment) => ({
160 key: attachment.id,
161 name: attachment.name,
162 url: attachment.previewUrl ?? null,
163 })),
164 pending: true,
165 })),
166 ];
167
168 const move = async (runId: RunId, beforeRunId: RunId | null) => {
169 setBusyRunId(runId);
170 try {
171 await reorder({
172 environmentId: props.environmentId,
173 input: { threadId: props.threadId, runId, beforeRunId },
174 });
175 } finally {
176 setBusyRunId(null);
177 }
178 };
179
180 const completeDrag = (runId: RunId, insertIndex: number | null) => {
181 setDragState(null);
182 dragArmedRunIdRef.current = null;
183 if (insertIndex === null || busyRunId !== null) return;
184 const draggedIndex = queued.findIndex(({ run }) => run.id === runId);
185 if (draggedIndex === -1) return;
186 // Inserting immediately before or after itself is a no-op.
187 if (insertIndex === draggedIndex || insertIndex === draggedIndex + 1) return;
188 void move(runId, queued[insertIndex]?.run.id ?? null);
189 };
190
191 const steerInFlightRef = useRef(false);
192 const steer = async (queuedRunId: RunId) => {
193 if (activeRun === null || !workflow?.canPromoteToSteer || steerInFlightRef.current) return;
194 steerInFlightRef.current = true;
195 setBusyRunId(queuedRunId);
196 try {
197 await promote({
198 environmentId: props.environmentId,
199 input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id },
200 });
201 } finally {
202 steerInFlightRef.current = false;
203 setBusyRunId(null);
204 }
205 };
206
207 useImperativeHandle(ref, () => ({
208 steerNext(repeat) {
209 const next = queued[0];
210 if (!next || !workflow?.canPromoteToSteer) return false;
211 if (!repeat && busyRunId === null) void steer(next.run.id);
212 return true;
213 },
214 // Declines while a queued message is already being edited so the key keeps
215 // moving the caret inside that draft.
216 editLatest(repeat) {
217 const latest = queued.at(-1);
218 if (!latest || props.editingRunId !== null || busyRunId !== null) return false;
219 if (!repeat) {
220 setExpanded(true);
221 props.onEditQueuedRun({
222 runId: latest.run.id,
223 messageId: latest.run.userMessageId,
224 text: latest.text,
225 attachments: latest.attachments,
226 });
227 }
228 return true;
229 },
230 }));
231
232 if (items.length === 0) return null;
233
234 const remove = async (runId: RunId) => {
235 setBusyRunId(runId);
236 try {
237 await cancel({
238 environmentId: props.environmentId,
239 input: { threadId: props.threadId, runId },
240 });
241 } finally {
242 setBusyRunId(null);
243 }
244 };
245
246 return (
247 <ComposerBanner.Attachment>
248 <ComposerBanner.Root
249 role="region"
250 aria-label={`${items.length} queued message${items.length === 1 ? "" : "s"}`}
251 aria-live="polite"
252 data-chat-composer-collapsed-controls="true"
253 className="relative z-0"
254 >
255 <ComposerBanner.Row
256 render={<button type="button" />}
257 aria-label={expanded ? "Collapse queued messages" : "Expand queued messages"}
258 aria-expanded={expanded}
259 aria-controls={queueListId}
260 onPointerDown={(event) => event.preventDefault()}
261 onClick={() => setExpanded((value) => !value)}
262 >
263 <ComposerBanner.Icon>
264 <ListOrderedIcon />
265 </ComposerBanner.Icon>
266 <ComposerBanner.Content className="text-muted-foreground">Queued</ComposerBanner.Content>
267 <ComposerBanner.Actions>
268 <ComposerBanner.Count>{items.length}</ComposerBanner.Count>
269 <ComposerBanner.ToggleIcon expanded={expanded} />
270 </ComposerBanner.Actions>
271 </ComposerBanner.Row>
272 <ComposerBanner.Scroll className={cn("max-h-32", !expanded && "hidden")}>
273 <ComposerBanner.Children render={<ol />} id={queueListId}>
274 {items.map((item) => {
275 const previewText = replaceComposerContextReferences(item.text, (reference) =>
276 reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label,
277 ).trim();
278 const rowRunId = item.runId;
279 const rowServerIndex = item.serverIndex;
280 const isEditing = rowRunId !== null && rowRunId === props.editingRunId;
281 const rowDraggable =
282 rowRunId !== null && rowServerIndex !== null && canReorder && busyRunId === null;
283 return (
284 <ComposerBanner.Row
285 render={<li />}
286 key={item.key}
287 aria-current={isEditing ? "true" : undefined}
288 className={cn(
289 "relative rounded-sm",
290 isEditing && "bg-accent text-accent-foreground",
291 dragState !== null && dragState.runId === item.runId && "opacity-50",
292 )}
293 draggable={rowDraggable}
294 onDragStart={(event) => {
295 if (item.runId === null || dragArmedRunIdRef.current !== item.runId) {
296 event.preventDefault();
297 return;
298 }
299 event.dataTransfer.setData(QUEUED_RUN_DRAG_TYPE, item.runId);
300 event.dataTransfer.effectAllowed = "move";
301 setDragState({ runId: item.runId, insertIndex: null });
302 }}
303 onDragEnd={() => {
304 dragArmedRunIdRef.current = null;
305 setDragState(null);
306 }}
307 onDragOver={(event) => {
308 if (dragState === null || item.serverIndex === null) return;
309 event.preventDefault();
310 event.dataTransfer.dropEffect = "move";
311 const rect = event.currentTarget.getBoundingClientRect();
312 const insertIndex =
313 event.clientY < rect.top + rect.height / 2
314 ? item.serverIndex
315 : item.serverIndex + 1;
316 if (dragState.insertIndex !== insertIndex) {
317 setDragState({ runId: dragState.runId, insertIndex });
318 }
319 }}
320 onDrop={(event) => {
321 if (dragState === null) return;
322 event.preventDefault();
323 completeDrag(dragState.runId, dragState.insertIndex);
324 }}
325 >
326 {item.serverIndex !== null && dragState?.insertIndex === item.serverIndex ? (
327 <span
328 aria-hidden
329 className="pointer-events-none absolute inset-x-0 top-0 h-0.5 rounded bg-primary/70"
330 />
331 ) : null}
332 {item.serverIndex === queued.length - 1 &&
333 dragState?.insertIndex === queued.length ? (
334 <span
335 aria-hidden
336 className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 rounded bg-primary/70"
337 />
338 ) : null}
339 <ComposerBanner.Icon aria-hidden={false}>
340 {canReorder && rowRunId !== null && rowServerIndex !== null ? (
341 <Button
342 size="icon-xs"
343 variant="ghost-muted"
344 aria-label="Reorder queued message (drag, or press the arrow keys)"
345 className="cursor-grab active:cursor-grabbing disabled:cursor-default"
346 disabled={busyRunId !== null}
347 onPointerDown={() => {
348 dragArmedRunIdRef.current = rowRunId;
349 }}
350 onKeyDown={(event) => {
351 if (busyRunId !== null) return;
352 if (event.key === "ArrowUp" && rowServerIndex > 0) {
353 event.preventDefault();
354 void move(rowRunId, queued[rowServerIndex - 1]?.run.id ?? null);
355 }
356 if (event.key === "ArrowDown" && rowServerIndex < queued.length - 1) {
357 event.preventDefault();
358 void move(rowRunId, queued[rowServerIndex + 2]?.run.id ?? null);
359 }
360 }}
361 >
362 <GripVerticalIcon />
363 </Button>
364 ) : null}
365 </ComposerBanner.Icon>
366 <ComposerBanner.Content className="text-foreground/80">
367 {isEditing ? <span className="sr-only">Editing queued message: </span> : null}
368 {item.pending ? (
369 <Clock3Icon
370 aria-label="Saving queued message"
371 className="size-3 shrink-0 text-muted-foreground/60"
372 />
373 ) : null}
374 {item.thumbnails.length > 0 ? (
375 <span className="flex shrink-0 items-center gap-0.5">
376 {item.thumbnails.map((thumbnail) => (
377 <span
378 key={thumbnail.key}
379 className="size-4 overflow-hidden rounded border border-border/70 bg-background"
380 >
381 {thumbnail.url ? (
382 <img
383 src={thumbnail.url}
384 alt={thumbnail.name}
385 className="size-full object-cover"
386 />
387 ) : (
388 <span
389 aria-label={thumbnail.name}
390 className="block size-full bg-muted/60"
391 />
392 )}
393 </span>
394 ))}
395 </span>
396 ) : null}
397 <Tooltip>
398 <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>
399 {previewText}
400 </TooltipTrigger>
401 <TooltipPopup side="top" className="max-w-96 break-words">
402 {previewText}
403 </TooltipPopup>
404 </Tooltip>
405 </ComposerBanner.Content>
406 <ComposerBanner.Actions>
407 {isEditing ? (
408 <Button
409 size="xs"
410 variant="ghost"
411 aria-label="Cancel editing queued message"
412 onClick={props.onCancelEdit}
413 >
414 Cancel
415 </Button>
416 ) : (
417 <>
418 <Tooltip>
419 <TooltipTrigger
420 render={
421 <Button
422 size="icon-xs"
423 variant="ghost-muted"
424 aria-label="Edit queued message"
425 disabled={item.runId === null || busyRunId !== null}
426 onClick={() => {
427 if (item.runId !== null && item.messageId !== null) {
428 props.onEditQueuedRun({
429 runId: item.runId,
430 messageId: item.messageId,
431 text: item.text,
432 attachments: item.attachments,
433 });
434 }
435 }}
436 />
437 }
438 >
439 <PencilIcon />
440 </TooltipTrigger>
441 <TooltipPopup>
442 {`Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`}
443 </TooltipPopup>
444 </Tooltip>
445 <Tooltip>
446 <TooltipTrigger render={<span className="flex shrink-0" />}>
447 <Button
448 size="xs"
449 variant="ghost-muted"
450 disabled={
451 item.runId === null ||
452 busyRunId !== null ||
453 !workflow?.canPromoteToSteer
454 }
455 onClick={() => {
456 if (item.runId !== null) {
457 void steer(item.runId);
458 }
459 }}
460 >
461 <CornerUpRightIcon />
462 Steer
463 </Button>
464 </TooltipTrigger>
465 <TooltipPopup>
466 {activeRun === null
467 ? "There is no active run to steer"
468 : `Send as a steer instead${item.serverIndex === 0 && props.steerShortcutLabel ? ` (${props.steerShortcutLabel})` : ""}`}
469 </TooltipPopup>
470 </Tooltip>
471 <Tooltip>
472 <TooltipTrigger
473 render={
474 <ComposerBanner.Dismiss
475 aria-label="Remove queued message"
476 disabled={item.runId === null || busyRunId !== null}
477 onClick={() => {
478 if (item.runId !== null) void remove(item.runId);
479 }}
480 />
481 }
482 />
483 <TooltipPopup>Remove from queue</TooltipPopup>
484 </Tooltip>
485 </>
486 )}
487 </ComposerBanner.Actions>
488 </ComposerBanner.Row>
489 );
490 })}
491 </ComposerBanner.Children>
492 </ComposerBanner.Scroll>
493 </ComposerBanner.Root>
494 </ComposerBanner.Attachment>
495 );
496}