| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; |
| 3 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| 4 | import type { |
| 5 | ChatAttachment as ContractChatAttachment, |
| 6 | EnvironmentId, |
| 7 | MessageId, |
| 8 | RunId, |
| 9 | ThreadId, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | Clock3Icon, |
| 13 | CornerUpRightIcon, |
| 14 | GripVerticalIcon, |
| 15 | ListOrderedIcon, |
| 16 | PencilIcon, |
| 17 | } from "lucide-react"; |
| 18 | import { useId, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; |
| 19 | |
| 20 | import { useAssetUrls } from "../../assets/assetUrls"; |
| 21 | import { threadEnvironment } from "../../state/threads"; |
| 22 | import { useThreadProjection } from "../../state/entities"; |
| 23 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 24 | import { isImageAttachment, type ChatMessage } from "../../types"; |
| 25 | import { cn } from "~/lib/utils"; |
| 26 | import { ComposerBanner } from "./ComposerBanner"; |
| 27 | import { Button } from "../ui/button"; |
| 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 29 | |
| 30 | export interface EditQueuedRunRequest { |
| 31 | readonly runId: RunId; |
| 32 | readonly messageId: MessageId; |
| 33 | readonly text: string; |
| 34 | readonly attachments: ReadonlyArray<ContractChatAttachment>; |
| 35 | } |
| 36 | |
| 37 | interface QueuedRowThumbnail { |
| 38 | readonly key: string; |
| 39 | readonly name: string; |
| 40 | readonly url: string | null; |
| 41 | } |
| 42 | |
| 43 | const QUEUED_RUN_DRAG_TYPE = "application/x-t3code-queued-run"; |
| 44 | |
| 45 | export interface QueuedRunsControlHandle { |
| 46 | steerNext: (repeat: boolean) => boolean; |
| 47 | editLatest: (repeat: boolean) => boolean; |
| 48 | } |
| 49 | |
| 50 | export 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 | } |