import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { isAttachmentOnlyBootstrapPrompt, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; import type { ChatAttachment as ContractChatAttachment, EnvironmentId, MessageId, RunId, ThreadId, } from "@t3tools/contracts"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; import { ArrowUpIcon, CalendarClockIcon, ChevronDownIcon, Clock3Icon, MessagesSquareIcon, PencilIcon, XIcon, } from "lucide-react"; import * as DateTime from "effect/DateTime"; import { useId, useImperativeHandle, useRef, useState, type ReactNode, type Ref } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { threadEnvironment } from "../../state/threads"; import { useQuietThreadShell, useThreadProjection } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { isImageAttachment, type ChatMessage } from "../../types"; import { cn } from "~/lib/utils"; import { Button, InlineButton } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { GuestMessageName } from "./guestMessageStyle"; import { TimelineRowTimestamp } from "./TimelineRowParts"; import { FoldedMessageHeader, useScheduledTaskTitle } from "./TimelineUserMessageRow"; 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; } // A longer queue shows its first messages and a button that shows the rest. const QUEUE_VISIBLE_ROWS = 3; export interface QueuedRunsControlHandle { steerNext: (repeat: boolean) => boolean; editLatest: (repeat: boolean) => boolean; } type ThreadQueueProjection = Parameters[0]; function queuedImageResources(projection: ThreadQueueProjection | undefined) { if (!projection) return []; return deriveThreadQueueWorkflowState(projection).queuedRuns.flatMap(({ attachments }) => attachments.flatMap((attachment) => attachment.type === "image" ? [{ _tag: "attachment" as const, attachmentId: attachment.id }] : [], ), ); } export function QueuedRunsControl({ ref, ...props }: { readonly ref?: Ref; readonly steerShortcutLabel?: string | null; readonly editShortcutLabel?: string | null; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly optimisticMessages: ReadonlyArray< Pick >; readonly editingRunId: RunId | null; /** Set while something else holds the composer, such as an open question. */ readonly editBlockedReason: string | null; readonly onEditQueuedRun: (request: EditQueuedRunRequest) => void; readonly onCancelEdit: () => void; readonly timestampFormat: TimestampFormat; readonly onOpenThread: (threadId: ThreadId) => void; }) { const projection = useThreadProjection( scopeThreadRef(props.environmentId, props.threadId), )?.projection; const promote = useAtomCommand(threadEnvironment.promoteQueuedRun); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun); const [showAll, setShowAll] = useState(false); const queueListId = useId(); const [busyRunId, setBusyRunId] = useState(null); const steerInFlightRef = useRef(false); // Run-only acknowledgements can resurrect pending rows after cancellation or startup. const acknowledgedMessageIds = new Set((projection?.messages ?? []).map((message) => message.id)); const queuedImageAttachmentResources = queuedImageResources(projection); const queuedImageAttachmentUrls = useAssetUrls( props.environmentId, queuedImageAttachmentResources, ); const workflow = projection ? deriveThreadQueueWorkflowState(projection) : null; const queued = workflow?.queuedRuns ?? []; const activeRun = workflow?.activeRun ?? null; const canSend = workflow?.canSendNow === true || workflow?.canPromoteToSteer === true; const queuedImageUrlById = new Map( queuedImageAttachmentUrls.flatMap((url, index) => { const resource = queuedImageAttachmentResources[index]; return url && resource ? [[resource.attachmentId, url] as const] : []; }), ); const optimisticQueued = props.optimisticMessages.filter( (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id), ); const items = [ ...queued.map(({ run, text, attachments, context, message }, serverIndex) => ({ key: run.id, message, runId: run.id, messageId: run.userMessageId, serverIndex, text, // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it. editable: context?.guest === undefined, 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, message: undefined, runId: null, messageId: null, serverIndex: null, text: message.text, editable: true, attachments: [] as ReadonlyArray, thumbnails: (message.attachments ?? []) .filter(isImageAttachment) .map((attachment) => ({ key: attachment.id, name: attachment.name, url: attachment.previewUrl ?? null, })), pending: true, })), ]; const sendQueuedMessage = async (queuedRunId: RunId) => { if (!canSend || steerInFlightRef.current) return; steerInFlightRef.current = true; setBusyRunId(queuedRunId); if (workflow?.canSendNow) { await resume({ environmentId: props.environmentId, input: { threadId: props.threadId, runId: queuedRunId }, }); } else if (activeRun !== null) { await promote({ environmentId: props.environmentId, input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id }, }); } steerInFlightRef.current = false; setBusyRunId(null); }; useImperativeHandle(ref, () => ({ steerNext(repeat) { const next = queued[0]; if (!next || !canSend) return false; if (!repeat && busyRunId === null) void sendQueuedMessage(next.run.id); return true; }, // Leave the key to caret navigation while editing. editLatest(repeat) { const latest = queued.at(-1); if ( !latest || latest.context?.guest !== undefined || props.editingRunId !== null || props.editBlockedReason !== null || busyRunId !== null ) { return false; } if (!repeat) { setShowAll(true); props.onEditQueuedRun({ runId: latest.run.id, messageId: latest.run.userMessageId, text: latest.text, attachments: latest.attachments, }); } return true; }, })); if (items.length === 0) return null; const remove = async (runId: RunId) => { setBusyRunId(runId); await cancel({ environmentId: props.environmentId, input: { threadId: props.threadId, runId }, }); setBusyRunId(null); }; const collapsible = items.length > QUEUE_VISIBLE_ROWS; const visibleItems = collapsible && !showAll ? items.slice(0, QUEUE_VISIBLE_ROWS - 1) : items; return (
{items.length} queued
    {visibleItems.map((item, position) => { const previewText = isAttachmentOnlyBootstrapPrompt(item.text) ? "" : assistantCitationsToPlainText( replaceComposerContextReferences(item.text, (reference) => reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, ), ).trim(); const isEditing = item.runId !== null && item.runId === props.editingRunId; return (
  1. {canSend && !isEditing ? ( <> }> {`${workflow?.canSendNow ? "Start a turn with this message" : workflow?.promoteSkipsCompaction ? "Skip compaction and send this message" : "Send into the current turn"}${item.serverIndex === 0 && props.steerShortcutLabel ? ` (${props.steerShortcutLabel})` : ""}`} ) : null} {item.pending ? ( Saving ) : null} {isEditing ? ( <> Editing in the composer ) : ( <> {/* The span keeps the tooltip, and its reason, on a disabled button. */} }> {props.editBlockedReason ?? `Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`} { if (item.runId !== null) void remove(item.runId); }} /> } > Remove from queue )}
  2. ); })}
{collapsible ? (
event.preventDefault()} onClick={() => setShowAll((value) => !value)} > {showAll ? "Show fewer" : `Show all ${items.length} queued`}
) : null}
); } type QueuedRunMessage = NonNullable; /** A queued turn wears its sent form's attribution; only the dashed edge and controls differ. */ function QueuedMessage(props: { readonly environmentId: EnvironmentId; readonly message: QueuedRunMessage | undefined; readonly previewText: string; readonly thumbnails: ReadonlyArray; readonly isEditing: boolean; readonly timestampFormat: TimestampFormat; readonly onOpenThread: (threadId: ThreadId) => void; readonly children: ReactNode; }) { const presentation = props.message ? resolveUserMessagePresentation(props.message) : null; const attribution = presentation?.attribution ?? null; const scheduledTaskId = presentation?.scheduledTaskId; const senderThreadId = props.message?.senderThreadId; const [expanded, setExpanded] = useState(false); const taskTitle = useScheduledTaskTitle(props.environmentId, scheduledTaskId); const senderShell = useQuietThreadShell( attribution === "agent" && senderThreadId ? { environmentId: props.environmentId, threadId: senderThreadId } : null, ); const fold = attribution === "automation" ? { icon: CalendarClockIcon, label: "Automation", summary: taskTitle ?? props.previewText.split(/\r?\n/, 1)[0] ?? "", } : attribution === "agent" ? { icon: MessagesSquareIcon, label: senderShell?.title.trim() || "Agent", summary: props.previewText.split(/\r?\n/).find((line) => line.trim()) ?? "", } : null; const bodyVisible = fold === null || expanded; return ( <> {attribution === "t3code" ? (

Sent by T3 Code

) : null} {presentation?.guestName === undefined ? null : ( )} {/* Dashed and unfilled: a turn that has not been sent yet. */}
{fold ? ( setExpanded((value) => !value)} /> ) : null} {bodyVisible ? (
{props.thumbnails.length > 0 ? ( {props.thumbnails.map((thumbnail) => ( {thumbnail.url ? ( {thumbnail.name} ) : ( )} ))} ) : null} {/* In full, like a sent turn: a queued message is read before it is sent. */} {props.previewText ? (

{props.previewText}

) : null}
) : null}
{props.message ? ( {attribution === "agent" && senderThreadId ? ( props.onOpenThread(senderThreadId)}> Open thread ) : null} {scheduledTaskId ? ( } > Open automation ) : null} ) : null} {props.children}
); }