Skip to content
Source
Sign inSign up

QueuedRunsControl.tsx

549 lines21.3 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
3import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
4import {
5 isAttachmentOnlyBootstrapPrompt,
6 replaceComposerContextReferences,
7} from "@t3tools/shared/composerContextReferences";
8import type {
9 ChatAttachment as ContractChatAttachment,
10 EnvironmentId,
11 MessageId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
16import { Link } from "@tanstack/react-router";
17import {
18 ArrowUpIcon,
19 CalendarClockIcon,
20 ChevronDownIcon,
21 Clock3Icon,
22 MessagesSquareIcon,
23 PencilIcon,
24 XIcon,
25} from "lucide-react";
26import * as DateTime from "effect/DateTime";
27import { useId, useImperativeHandle, useRef, useState, type ReactNode, type Ref } from "react";
28​
29import { useAssetUrls } from "../../assets/assetUrls";
30import { threadEnvironment } from "../../state/threads";
31import { useQuietThreadShell, useThreadProjection } from "../../state/entities";
32import { useEnvironment } from "../../state/environments";
33import { useAtomCommand } from "../../state/use-atom-command";
34import { isImageAttachment, type ChatMessage } from "../../types";
35import { cn } from "~/lib/utils";
36import { Button, InlineButton } from "../ui/button";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
38import type { TimestampFormat } from "@t3tools/contracts/settings";
39import { GuestMessageName } from "./guestMessageStyle";
40import { TimelineRowTimestamp } from "./TimelineRowParts";
41import { FoldedMessageHeader, useScheduledTaskTitle } from "./TimelineUserMessageRow";
42​
43export interface EditQueuedRunRequest {
44 readonly runId: RunId;
45 readonly messageId: MessageId;
46 readonly text: string;
47 readonly attachments: ReadonlyArray<ContractChatAttachment>;
49​
50interface QueuedRowThumbnail {
51 readonly key: string;
52 readonly name: string;
53 readonly url: string | null;
55​
56// A longer queue shows its first messages and a button that shows the rest.
57const QUEUE_VISIBLE_ROWS = 3;
58​
59export interface QueuedRunsControlHandle {
60 steerNext: (repeat: boolean) => boolean;
61 editLatest: (repeat: boolean) => boolean;
63​
64type ThreadQueueProjection = Parameters<typeof deriveThreadQueueWorkflowState>[0];
65​
66function queuedImageResources(projection: ThreadQueueProjection | undefined) {
67 if (!projection) return [];
68 return deriveThreadQueueWorkflowState(projection).queuedRuns.flatMap(({ attachments }) =>
69 attachments.flatMap((attachment) =>
70 attachment.type === "image"
71 ? [{ _tag: "attachment" as const, attachmentId: attachment.id }]
72 : [],
73 ),
74 );
76​
77export function QueuedRunsControl({
78 ref,
79 ...props
80}: {
81 readonly ref?: Ref<QueuedRunsControlHandle>;
82 readonly steerShortcutLabel?: string | null;
83 readonly editShortcutLabel?: string | null;
84 readonly environmentId: EnvironmentId;
85 readonly threadId: ThreadId;
86 readonly optimisticMessages: ReadonlyArray<
87 Pick<ChatMessage, "id" | "inputIntent" | "text" | "attachments">
88 >;
89 readonly editingRunId: RunId | null;
90 /** Set while something else holds the composer, such as an open question. */
91 readonly editBlockedReason: string | null;
92 readonly onEditQueuedRun: (request: EditQueuedRunRequest) => void;
93 readonly onCancelEdit: () => void;
94 readonly timestampFormat: TimestampFormat;
95 readonly onOpenThread: (threadId: ThreadId, environmentId?: EnvironmentId) => void;
96}) {
97 const projection = useThreadProjection(
98 scopeThreadRef(props.environmentId, props.threadId),
99 )?.projection;
100 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
101 const resume = useAtomCommand(threadEnvironment.resumeThreadQueue);
102 const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun);
103 const [showAll, setShowAll] = useState(false);
104 const queueListId = useId();
105 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
106 const steerInFlightRef = useRef(false);
107 // Run-only acknowledgements can resurrect pending rows after cancellation or startup.
108 const acknowledgedMessageIds = new Set((projection?.messages ?? []).map((message) => message.id));
109 const queuedImageAttachmentResources = queuedImageResources(projection);
110 const queuedImageAttachmentUrls = useAssetUrls(
111 props.environmentId,
112 queuedImageAttachmentResources,
113 );
114 const workflow = projection ? deriveThreadQueueWorkflowState(projection) : null;
115 const queued = workflow?.queuedRuns ?? [];
116 const activeRun = workflow?.activeRun ?? null;
117 // A fresh message starts its own turn: it may skip compaction, but not steer into a turn.
118 const canSendRun = (freshContext: boolean) =>
119 workflow?.canSendNow === true ||
120 (workflow?.canPromoteToSteer === true &&
121 (!freshContext || workflow.promoteSkipsCompaction === true));
122 const queuedImageUrlById = new Map(
123 queuedImageAttachmentUrls.flatMap((url, index) => {
124 const resource = queuedImageAttachmentResources[index];
125 return url && resource ? [[resource.attachmentId, url] as const] : [];
126 }),
127 );
128 const optimisticQueued = props.optimisticMessages.filter(
129 (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id),
130 );
131 const items = [
132 ...queued.map(({ run, text, attachments, context, message }, serverIndex) => ({
133 key: run.id,
134 message,
135 runId: run.id,
136 messageId: run.userMessageId,
137 freshContext: run.freshContext === true,
138 serverIndex,
139 text,
140 // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it.
141 editable: context?.guest === undefined,
142 attachments,
143 thumbnails: attachments
144 .filter((attachment) => attachment.type === "image")
145 .map<QueuedRowThumbnail>((attachment) => ({
146 key: attachment.id,
147 name: attachment.name,
148 url: queuedImageUrlById.get(attachment.id) ?? null,
149 })),
150 pending: false,
151 })),
152 ...optimisticQueued.map((message) => ({
153 key: message.id,
154 message: undefined,
155 runId: null,
156 messageId: null,
157 freshContext: false,
158 serverIndex: null,
159 text: message.text,
160 editable: true,
161 attachments: [] as ReadonlyArray<ContractChatAttachment>,
162 thumbnails: (message.attachments ?? [])
163 .filter(isImageAttachment)
164 .map<QueuedRowThumbnail>((attachment) => ({
165 key: attachment.id,
166 name: attachment.name,
167 url: attachment.previewUrl ?? null,
168 })),
169 pending: true,
170 })),
171 ];
172​
173 const sendQueuedMessage = async (queuedRunId: RunId) => {
174 const freshContext = queued.some(
175 ({ run }) => run.id === queuedRunId && run.freshContext === true,
176 );
177 if (!canSendRun(freshContext) || steerInFlightRef.current) return;
178 steerInFlightRef.current = true;
179 setBusyRunId(queuedRunId);
180 if (workflow?.canSendNow) {
181 await resume({
182 environmentId: props.environmentId,
183 input: { threadId: props.threadId, runId: queuedRunId },
184 });
185 } else if (activeRun !== null) {
186 await promote({
187 environmentId: props.environmentId,
188 input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id },
189 });
190 }
191 steerInFlightRef.current = false;
192 setBusyRunId(null);
193 };
194​
195 useImperativeHandle(ref, () => ({
196 steerNext(repeat) {
197 const next = queued[0];
198 if (!next || !canSendRun(next.run.freshContext === true)) return false;
199 if (!repeat && busyRunId === null) void sendQueuedMessage(next.run.id);
200 return true;
201 },
202 // Leave the key to caret navigation while editing.
203 editLatest(repeat) {
204 const latest = queued.at(-1);
205 if (
206 !latest ||
207 latest.context?.guest !== undefined ||
208 props.editingRunId !== null ||
209 props.editBlockedReason !== null ||
210 busyRunId !== null
211 ) {
212 return false;
213 }
214 if (!repeat) {
215 setShowAll(true);
216 props.onEditQueuedRun({
217 runId: latest.run.id,
218 messageId: latest.run.userMessageId,
219 text: latest.text,
220 attachments: latest.attachments,
221 });
222 }
223 return true;
224 },
225 }));
226​
227 if (items.length === 0) return null;
228​
229 const remove = async (runId: RunId) => {
230 setBusyRunId(runId);
231 await cancel({
232 environmentId: props.environmentId,
233 input: { threadId: props.threadId, runId },
234 });
235 setBusyRunId(null);
236 };
237​
238 const collapsible = items.length > QUEUE_VISIBLE_ROWS;
239 const visibleItems = collapsible && !showAll ? items.slice(0, QUEUE_VISIBLE_ROWS - 1) : items;
240​
241 return (
242 <section
243 aria-label={`${items.length} queued message${items.length === 1 ? "" : "s"}`}
244 data-thread-queue="true"
245 // Anchors the sr-only live region; unanchored, it escapes the timeline scroller and makes the chat column scroll.
246 className="relative mx-auto flex w-full max-w-(--chat-content-max-width) flex-col gap-2 pb-4"
247 >
248 <span className="sr-only" aria-live="polite">
249 {items.length} queued
250 </span>
251 <ol className="flex flex-col items-end gap-3" id={queueListId}>
252 {visibleItems.map((item, position) => {
253 const previewText = isAttachmentOnlyBootstrapPrompt(item.text)
254 ? ""
255 : assistantCitationsToPlainText(
256 replaceComposerContextReferences(item.text, (reference) => reference.label),
257 ).trim();
258 const isEditing = item.runId !== null && item.runId === props.editingRunId;
259 return (
260 <li
261 key={item.key}
262 aria-label={`Queued message ${position + 1} of ${items.length}, not sent`}
263 aria-posinset={position + 1}
264 aria-setsize={items.length}
265 aria-current={isEditing ? "true" : undefined}
266 className="group/queued flex w-full flex-col items-end gap-1"
267 >
268 <QueuedMessage
269 environmentId={props.environmentId}
270 message={item.message}
271 previewText={previewText}
272 thumbnails={item.thumbnails}
273 isEditing={isEditing}
274 timestampFormat={props.timestampFormat}
275 onOpenThread={props.onOpenThread}
276 >
277 {canSendRun(item.freshContext) && !isEditing ? (
278 <>
279 <Tooltip>
280 <TooltipTrigger render={<span className="flex shrink-0" />}>
281 <Button
282 size="xs"
283 variant="ghost-muted"
284 disabled={item.runId === null || busyRunId !== null}
285 onClick={() => {
286 if (item.runId !== null) {
287 void sendQueuedMessage(item.runId);
288 }
289 }}
290 >
291 <ArrowUpIcon />
292 Send now
293 </Button>
294 </TooltipTrigger>
295 <TooltipPopup>
296 {`${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})` : ""}`}
297 </TooltipPopup>
298 </Tooltip>
299 </>
300 ) : null}
301 {item.pending ? (
302 <span className="flex items-center gap-1 px-1">
303 <Clock3Icon aria-hidden className="size-3" />
304 Saving
305 </span>
306 ) : null}
307 {isEditing ? (
308 <>
309 <span className="px-1 text-foreground">Editing in the composer</span>
310 <Button
311 size="xs"
312 variant="ghost"
313 aria-label="Cancel editing queued message"
314 onClick={props.onCancelEdit}
315 >
316 Cancel
317 </Button>
318 </>
319 ) : (
320 <>
321 <Tooltip>
322 {/* The span keeps the tooltip, and its reason, on a disabled button. */}
323 <TooltipTrigger render={<span className="flex shrink-0" />}>
324 <Button
325 size="icon-xs"
326 variant="ghost-muted"
327 aria-label="Edit queued message"
328 disabled={
329 item.runId === null ||
330 !item.editable ||
331 busyRunId !== null ||
332 props.editBlockedReason !== null
333 }
334 onClick={() => {
335 if (item.runId !== null && item.messageId !== null) {
336 props.onEditQueuedRun({
337 runId: item.runId,
338 messageId: item.messageId,
339 text: item.text,
340 attachments: item.attachments,
341 });
342 }
343 }}
344 >
345 <PencilIcon />
346 </Button>
347 </TooltipTrigger>
348 <TooltipPopup>
349 {props.editBlockedReason ??
350 `Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`}
351 </TooltipPopup>
352 </Tooltip>
353 <Tooltip>
354 <TooltipTrigger
355 render={
356 <Button
357 size="icon-xs"
358 variant="ghost-muted"
359 aria-label="Remove queued message"
360 disabled={item.runId === null || busyRunId !== null}
361 onClick={() => {
362 if (item.runId !== null) void remove(item.runId);
363 }}
364 />
365 }
366 >
367 <XIcon />
368 </TooltipTrigger>
369 <TooltipPopup>Remove from queue</TooltipPopup>
370 </Tooltip>
371 </>
372 )}
373 </QueuedMessage>
374 </li>
375 );
376 })}
377 </ol>
378 {collapsible ? (
379 <div className="self-end pe-1 text-xs pointer-coarse:[&_[data-slot=inline-button]]:relative pointer-coarse:[&_[data-slot=inline-button]]:after:absolute pointer-coarse:[&_[data-slot=inline-button]]:after:-inset-x-1.5 pointer-coarse:[&_[data-slot=inline-button]]:after:-inset-y-3.5">
380 <InlineButton
381 tone="muted"
382 aria-expanded={showAll}
383 aria-controls={queueListId}
384 // Keep the composer focused; keyboard activation still works.
385 onPointerDown={(event) => event.preventDefault()}
386 onClick={() => setShowAll((value) => !value)}
387 >
388 {showAll ? "Show fewer" : `Show all ${items.length} queued`}
389 <ChevronDownIcon
390 aria-hidden
391 className={cn(
392 "size-3.5 transition-transform duration-150 motion-reduce:transition-none",
393 showAll && "rotate-180",
394 )}
395 />
396 </InlineButton>
397 </div>
398 ) : null}
399 </section>
400 );
402​
403type QueuedRunMessage = NonNullable<ThreadQueueProjection["messages"][number]>;
404​
405/** A queued turn wears its sent form's attribution; only the dashed edge and controls differ. */
406function QueuedMessage(props: {
407 readonly environmentId: EnvironmentId;
408 readonly message: QueuedRunMessage | undefined;
409 readonly previewText: string;
410 readonly thumbnails: ReadonlyArray<QueuedRowThumbnail>;
411 readonly isEditing: boolean;
412 readonly timestampFormat: TimestampFormat;
413 readonly onOpenThread: (threadId: ThreadId, environmentId?: EnvironmentId) => void;
414 readonly children: ReactNode;
415}) {
416 const presentation = props.message ? resolveUserMessagePresentation(props.message) : null;
417 const attribution = presentation?.attribution ?? null;
418 const scheduledTaskId = presentation?.scheduledTaskId;
419 const senderThreadId = props.message?.senderThreadId;
420 // A linked environment's agent sent it from a thread there.
421 const senderRef =
422 props.message?.linkedSender ??
423 (senderThreadId === undefined ? null : scopeThreadRef(props.environmentId, senderThreadId));
424 const [expanded, setExpanded] = useState(false);
425 const taskTitle = useScheduledTaskTitle(props.environmentId, scheduledTaskId);
426 const senderShell = useQuietThreadShell(attribution === "agent" ? senderRef : null);
427 const linkedSenderEnvironment = useEnvironment(
428 props.message?.linkedSender?.environmentId ?? null,
429 );
430 const fold =
431 attribution === "automation"
432 ? {
433 icon: CalendarClockIcon,
434 label: "Automation",
435 summary: taskTitle ?? props.previewText.split(/\r?\n/, 1)[0] ?? "",
436 }
437 : attribution === "agent"
438 ? {
439 icon: MessagesSquareIcon,
440 label:
441 senderShell?.title.trim() || props.message?.linkedSender?.title.trim() || "Agent",
442 summary: props.previewText.split(/\r?\n/).find((line) => line.trim()) ?? "",
443 }
444 : null;
445 const bodyVisible = fold === null || expanded;
446​
447 return (
448 <>
449 {attribution === "t3code" ? (
450 <p className="me-1 text-2xs text-muted-foreground/70">Sent by T3 Code</p>
451 ) : null}
452 {presentation?.guestName === undefined ? null : (
453 <GuestMessageName name={presentation.guestName} />
454 )}
455 {/* Dashed and unfilled: a turn that has not been sent yet. */}
456 <div
457 className={cn(
458 "flex min-w-0 max-w-[80%] flex-col rounded-2xl border border-dashed text-sm",
459 props.isEditing
460 ? "border-primary/60 text-foreground"
461 : attribution === "guest"
462 ? "border-guest/50 text-muted-foreground"
463 : "border-muted-foreground/40 text-muted-foreground",
464 fold === null && "px-3 py-2",
465 fold !== null && expanded && "w-full",
466 )}
467 >
468 {fold ? (
469 <FoldedMessageHeader
470 icon={fold.icon}
471 label={fold.label}
472 summary={fold.summary}
473 expanded={expanded}
474 onToggle={() => setExpanded((value) => !value)}
475 />
476 ) : null}
477 {bodyVisible ? (
478 <div
479 className={cn(
480 "flex flex-col gap-2",
481 fold !== null && "max-h-96 overflow-auto px-3 pb-2",
482 )}
483 >
484 {props.thumbnails.length > 0 ? (
485 <span className="flex flex-wrap items-center gap-1">
486 {props.thumbnails.map((thumbnail) => (
487 <span
488 key={thumbnail.key}
489 className="size-8 overflow-hidden rounded-md border border-border/70 bg-background"
490 >
491 {thumbnail.url ? (
492 <img
493 src={thumbnail.url}
494 alt={thumbnail.name}
495 className="size-full object-cover"
496 />
497 ) : (
498 <span aria-label={thumbnail.name} className="block size-full bg-muted/60" />
499 )}
500 </span>
501 ))}
502 </span>
503 ) : null}
504 {/* In full, like a sent turn: a queued message is read before it is sent. */}
505 {props.previewText ? (
506 <p className="whitespace-pre-wrap wrap-anywhere">{props.previewText}</p>
507 ) : null}
508 </div>
509 ) : null}
510 </div>
511 <div className="flex items-center gap-0.5 pe-1 text-xs text-muted-foreground">
512 {props.message ? (
513 <span className="me-1 flex items-center gap-2 px-1 opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/queued:opacity-100">
514 {attribution === "agent" &&
515 senderRef &&
516 // Only an environment this client is connected to can open the sender's thread there.
517 (props.message?.linkedSender === undefined || linkedSenderEnvironment !== null) ? (
518 <InlineButton
519 tone="muted"
520 onClick={() => props.onOpenThread(senderRef.threadId, senderRef.environmentId)}
521 >
522 Open thread
523 </InlineButton>
524 ) : null}
525 {scheduledTaskId ? (
526 <InlineButton
527 tone="muted"
528 render={
529 <Link
530 to="/settings/scheduled-tasks"
531 search={{ environmentId: props.environmentId, taskId: scheduledTaskId }}
532 />
533 }
534 >
535 Open automation
536 </InlineButton>
537 ) : null}
538 <TimelineRowTimestamp
539 createdAt={DateTime.formatIso(props.message.createdAt)}
540 timestampFormat={props.timestampFormat}
541 alwaysVisible
542 />
543 </span>
544 ) : null}
545 {props.children}
546 </div>
547 </>
548 );