@opengeni/react 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. package/dist/chunk-NFYVQWIB.js.map +0 -1
@@ -0,0 +1,578 @@
1
+ import {
2
+ DndContext,
3
+ DragOverlay,
4
+ PointerSensor,
5
+ closestCenter,
6
+ useSensor,
7
+ useSensors,
8
+ type DragEndEvent,
9
+ type DragStartEvent,
10
+ type Modifier,
11
+ } from "@dnd-kit/core";
12
+ import {
13
+ SortableContext,
14
+ arrayMove,
15
+ useSortable,
16
+ verticalListSortingStrategy,
17
+ } from "@dnd-kit/sortable";
18
+ import { CSS } from "@dnd-kit/utilities";
19
+ import type { SessionTurn } from "@opengeni/sdk";
20
+ import {
21
+ ArrowDownToLineIcon,
22
+ ArrowUpToLineIcon,
23
+ ChevronDownIcon,
24
+ EllipsisIcon,
25
+ GripVerticalIcon,
26
+ Loader2Icon,
27
+ PencilIcon,
28
+ RotateCwIcon,
29
+ Trash2Icon,
30
+ ZapIcon,
31
+ } from "lucide-react";
32
+ import { useCallback, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
33
+
34
+ import { DropdownMenu } from "radix-ui";
35
+ import type { ComposerState } from "../hooks/use-composer";
36
+ import type { QueueMutationKind, UseTurnQueueResult } from "../hooks/use-turn-queue";
37
+
38
+ /** The sole human prompt queue: compact above Goal, Agents, and composer. */
39
+ export type QueueSurfaceProps =
40
+ | {
41
+ queue: UseTurnQueueResult;
42
+ composer: ComposerState;
43
+ readOnly?: false | undefined;
44
+ }
45
+ | {
46
+ queue: UseTurnQueueResult;
47
+ composer?: undefined;
48
+ readOnly: true;
49
+ };
50
+
51
+ export function QueueSurface({ queue, composer, readOnly = false }: QueueSurfaceProps) {
52
+ const [open, setOpen] = useState(false);
53
+ const [replaceDraftFor, setReplaceDraftFor] = useState<string | null>(null);
54
+ const [announcement, setAnnouncement] = useState("");
55
+ const [draggedTurnId, setDraggedTurnId] = useState<string | null>(null);
56
+ const [keyboardDrag, setKeyboardDrag] = useState<{
57
+ turnId: string;
58
+ projectedIndex: number;
59
+ } | null>(null);
60
+ const count = queue.queue.length;
61
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
62
+
63
+ const displayedQueue = useMemo(() => {
64
+ if (!keyboardDrag) return queue.queue;
65
+ const oldIndex = queue.queue.findIndex((turn) => turn.id === keyboardDrag.turnId);
66
+ if (oldIndex < 0) return queue.queue;
67
+ return arrayMove(queue.queue, oldIndex, keyboardDrag.projectedIndex);
68
+ }, [keyboardDrag, queue.queue]);
69
+
70
+ const ids = useMemo(() => displayedQueue.map((turn) => turn.id), [displayedQueue]);
71
+ const moveToIndex = useCallback(
72
+ async (turnId: string, nextIndex: number): Promise<void> => {
73
+ const oldIndex = queue.queue.findIndex((turn) => turn.id === turnId);
74
+ if (oldIndex < 0) return;
75
+ const boundedIndex = Math.max(0, Math.min(nextIndex, queue.queue.length - 1));
76
+ if (oldIndex === boundedIndex) return;
77
+ const ordered = arrayMove(queue.queue, oldIndex, boundedIndex);
78
+ const beforeTurnId = ordered[boundedIndex + 1]?.id ?? null;
79
+ const moved = await queue.moveTurn(turnId, beforeTurnId);
80
+ setAnnouncement(
81
+ moved
82
+ ? `Queued prompt moved to position ${boundedIndex + 1}.`
83
+ : "The queue changed before that prompt could be moved. Refreshed server order.",
84
+ );
85
+ if (moved) focusQueueTurn(turnId);
86
+ },
87
+ [queue],
88
+ );
89
+
90
+ const onDragEnd = useCallback(
91
+ (event: DragEndEvent) => {
92
+ setDraggedTurnId(null);
93
+ const activeId = String(event.active.id);
94
+ const overId = event.over ? String(event.over.id) : null;
95
+ if (!overId || activeId === overId) {
96
+ setAnnouncement("Queued prompt returned to its original position.");
97
+ return;
98
+ }
99
+ const nextIndex = queue.queue.findIndex((turn) => turn.id === overId);
100
+ if (nextIndex >= 0) void moveToIndex(activeId, nextIndex);
101
+ },
102
+ [moveToIndex, queue.queue],
103
+ );
104
+
105
+ const onDragStart = useCallback(
106
+ (event: DragStartEvent) => {
107
+ setKeyboardDrag(null);
108
+ const turnId = String(event.active.id);
109
+ const position = queue.queue.findIndex((turn) => turn.id === turnId) + 1;
110
+ setDraggedTurnId(turnId);
111
+ setAnnouncement(`Dragging queued prompt ${position} of ${queue.queue.length}.`);
112
+ },
113
+ [queue.queue],
114
+ );
115
+
116
+ const onHandleKeyDown = useCallback(
117
+ (event: ReactKeyboardEvent<HTMLButtonElement>, turnId: string) => {
118
+ const canonicalIndex = queue.queue.findIndex((turn) => turn.id === turnId);
119
+ if (canonicalIndex < 0) return;
120
+
121
+ if (!keyboardDrag) {
122
+ if (event.key !== " ") return;
123
+ event.preventDefault();
124
+ setKeyboardDrag({ turnId, projectedIndex: canonicalIndex });
125
+ setAnnouncement(
126
+ `Lifted queued prompt ${canonicalIndex + 1} of ${count}. Use arrow keys to move it, then press Space to drop.`,
127
+ );
128
+ return;
129
+ }
130
+ if (keyboardDrag.turnId !== turnId) return;
131
+
132
+ if (event.key === "Escape") {
133
+ event.preventDefault();
134
+ setKeyboardDrag(null);
135
+ setAnnouncement("Queue reorder cancelled.");
136
+ return;
137
+ }
138
+ if (event.key === " ") {
139
+ event.preventDefault();
140
+ const targetIndex = keyboardDrag.projectedIndex;
141
+ setAnnouncement(`Moving queued prompt to position ${targetIndex + 1}.`);
142
+ void moveToIndex(turnId, targetIndex).finally(() => setKeyboardDrag(null));
143
+ return;
144
+ }
145
+
146
+ const direction = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
147
+ const edge = event.key === "Home" ? 0 : event.key === "End" ? count - 1 : null;
148
+ if (direction === 0 && edge === null) return;
149
+ event.preventDefault();
150
+ const projectedIndex = Math.max(
151
+ 0,
152
+ Math.min(edge ?? keyboardDrag.projectedIndex + direction, count - 1),
153
+ );
154
+ setKeyboardDrag({ turnId, projectedIndex });
155
+ setAnnouncement(`Queued prompt projected to position ${projectedIndex + 1} of ${count}.`);
156
+ },
157
+ [count, keyboardDrag, moveToIndex, queue.queue],
158
+ );
159
+
160
+ const edit = useCallback(
161
+ async (turn: SessionTurn, replaceDraft: boolean) => {
162
+ if (!composer || readOnly) return;
163
+ const restored = await queue.editTurn(turn.id, {
164
+ expectedDraftRevision: composer.draftRevision,
165
+ replaceDraft,
166
+ });
167
+ if (!restored) return;
168
+ composer.applyDraft(restored);
169
+ setReplaceDraftFor(null);
170
+ setAnnouncement("Queued prompt moved back to the composer for editing.");
171
+ window.requestAnimationFrame(() => {
172
+ const input = document.querySelector<HTMLTextAreaElement>(
173
+ 'textarea[aria-label="Message the agent"]',
174
+ );
175
+ input?.scrollIntoView({ block: "nearest", behavior: "smooth" });
176
+ input?.focus();
177
+ });
178
+ },
179
+ [composer, queue, readOnly],
180
+ );
181
+
182
+ const requestEdit = useCallback(
183
+ (turn: SessionTurn) => {
184
+ if (!composer || readOnly) return;
185
+ const draftDirty =
186
+ composer.value.length > 0 ||
187
+ composer.restoredResources.length > 0 ||
188
+ (composer.draft?.tools.length ?? 0) > 0 ||
189
+ (composer.draft?.sourceTurnId !== null && composer.draft?.sourceTurnId !== undefined);
190
+ if (draftDirty) {
191
+ setReplaceDraftFor(turn.id);
192
+ } else {
193
+ void edit(turn, false);
194
+ }
195
+ },
196
+ [composer, edit, readOnly],
197
+ );
198
+
199
+ if (count === 0 && !queue.error && !queue.mutationError) return null;
200
+
201
+ return (
202
+ <div
203
+ className="mx-auto mb-2 w-full max-w-3xl shrink-0 px-4 sm:px-6"
204
+ data-testid="queue-surface"
205
+ >
206
+ <div className="overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm">
207
+ <button
208
+ type="button"
209
+ className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left outline-none transition-colors hover:bg-surface-2/60 focus-visible:bg-surface-2/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40 pointer-coarse:min-h-11"
210
+ onClick={() => setOpen((value) => !value)}
211
+ aria-expanded={open}
212
+ >
213
+ <ChevronDownIcon
214
+ className={`size-3.5 shrink-0 text-fg-subtle transition-transform ${open ? "rotate-180" : ""}`}
215
+ />
216
+ <span className="text-xs font-medium text-fg">
217
+ {count} queued prompt{count === 1 ? "" : "s"}
218
+ </span>
219
+ {readOnly ? (
220
+ <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-2xs text-fg-subtle">
221
+ Read-only
222
+ </span>
223
+ ) : null}
224
+ {!open && count > 0 ? (
225
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
226
+ {queue.queue[0]?.prompt}
227
+ </span>
228
+ ) : null}
229
+ {queue.loading ? <Loader2Icon className="ml-auto size-3.5 animate-spin" /> : null}
230
+ </button>
231
+
232
+ {open && count > 0 && readOnly ? (
233
+ <ol className="divide-y divide-border border-t border-border" aria-label="Queued prompts">
234
+ {queue.queue.map((turn, index) => (
235
+ <ReadOnlyQueueRow key={turn.id} turn={turn} index={index} />
236
+ ))}
237
+ </ol>
238
+ ) : null}
239
+
240
+ {open && count > 0 && !readOnly ? (
241
+ <DndContext
242
+ sensors={sensors}
243
+ collisionDetection={closestCenter}
244
+ modifiers={[verticalOnly]}
245
+ onDragStart={onDragStart}
246
+ onDragCancel={() => {
247
+ setDraggedTurnId(null);
248
+ setAnnouncement("Queue reorder cancelled.");
249
+ }}
250
+ onDragEnd={onDragEnd}
251
+ >
252
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
253
+ <ol
254
+ className="divide-y divide-border border-t border-border"
255
+ aria-label="Queued prompts"
256
+ >
257
+ {displayedQueue.map((turn, index) => (
258
+ <SortableQueueRow
259
+ key={turn.id}
260
+ turn={turn}
261
+ index={index}
262
+ count={count}
263
+ pending={queue.mutationFor(turn.id)}
264
+ confirmingReplace={replaceDraftFor === turn.id}
265
+ keyboardDragging={keyboardDrag?.turnId === turn.id}
266
+ onHandleKeyDown={(event) => onHandleKeyDown(event, turn.id)}
267
+ onMove={(nextIndex) => void moveToIndex(turn.id, nextIndex)}
268
+ onEdit={() => requestEdit(turn)}
269
+ onConfirmReplace={() => void edit(turn, true)}
270
+ onCancelReplace={() => setReplaceDraftFor(null)}
271
+ onSteer={() => {
272
+ void queue.steerTurn(turn.id).then((steered) => {
273
+ setAnnouncement(
274
+ steered
275
+ ? "Queued prompt is now the next direction."
276
+ : "That prompt changed before it could be steered.",
277
+ );
278
+ if (steered) focusAfterQueueRemoval(index);
279
+ });
280
+ }}
281
+ onDelete={() => {
282
+ void queue.removeTurn(turn.id).then((removed) => {
283
+ setAnnouncement(
284
+ removed
285
+ ? "Queued prompt deleted."
286
+ : "That prompt changed before it could be deleted.",
287
+ );
288
+ if (removed) focusAfterQueueRemoval(index);
289
+ });
290
+ }}
291
+ />
292
+ ))}
293
+ </ol>
294
+ </SortableContext>
295
+ <DragOverlay modifiers={[verticalOnly]}>
296
+ {draggedTurnId ? (
297
+ <div className="max-w-xl rounded-md border border-brand/40 bg-surface px-3 py-2 text-xs text-fg shadow-lg">
298
+ {queue.queue.find((turn) => turn.id === draggedTurnId)?.prompt}
299
+ </div>
300
+ ) : null}
301
+ </DragOverlay>
302
+ </DndContext>
303
+ ) : null}
304
+
305
+ {queue.error || queue.mutationError ? (
306
+ <div className="border-t border-border p-2">
307
+ <div
308
+ role="alert"
309
+ className="flex items-center gap-2 rounded-md bg-status-failed/10 px-2 py-1.5 text-xs text-status-failed"
310
+ >
311
+ <span className="min-w-0 flex-1">
312
+ {(queue.mutationError ?? queue.error)?.message}
313
+ </span>
314
+ <button
315
+ type="button"
316
+ onClick={() => {
317
+ queue.clearMutationError();
318
+ void queue.refresh();
319
+ }}
320
+ aria-label="Dismiss queue error and retry"
321
+ title="Retry loading the queue"
322
+ className="inline-flex size-7 items-center justify-center rounded-md outline-none transition-colors hover:bg-surface-3 focus-visible:ring-2 focus-visible:ring-ring/40 pointer-coarse:size-11"
323
+ >
324
+ <RotateCwIcon className="size-3.5" />
325
+ </button>
326
+ </div>
327
+ </div>
328
+ ) : null}
329
+ </div>
330
+ <p className="sr-only" aria-live="polite" aria-atomic="true">
331
+ {announcement}
332
+ </p>
333
+ </div>
334
+ );
335
+ }
336
+
337
+ function ReadOnlyQueueRow({ turn, index }: { turn: SessionTurn; index: number }) {
338
+ return (
339
+ <li className="flex min-w-0 items-start gap-2 bg-surface px-3 py-2">
340
+ <span className="mt-1 shrink-0 font-mono text-2xs text-fg-subtle">{index + 1}</span>
341
+ <div className="min-w-0 flex-1">
342
+ <p className="whitespace-pre-wrap break-words text-xs leading-5 text-fg">{turn.prompt}</p>
343
+ <div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-2xs text-fg-subtle">
344
+ {turn.resources.length > 0 ? (
345
+ <span>
346
+ {turn.resources.length} resource{turn.resources.length === 1 ? "" : "s"}
347
+ </span>
348
+ ) : null}
349
+ {turn.tools.length > 0 ? (
350
+ <span>
351
+ {turn.tools.length} tool{turn.tools.length === 1 ? "" : "s"}
352
+ </span>
353
+ ) : null}
354
+ <span>{turn.model}</span>
355
+ <span>{turn.reasoningEffort}</span>
356
+ </div>
357
+ </div>
358
+ </li>
359
+ );
360
+ }
361
+
362
+ function SortableQueueRow({
363
+ turn,
364
+ index,
365
+ count,
366
+ pending,
367
+ confirmingReplace,
368
+ keyboardDragging,
369
+ onHandleKeyDown,
370
+ onMove,
371
+ onEdit,
372
+ onConfirmReplace,
373
+ onCancelReplace,
374
+ onSteer,
375
+ onDelete,
376
+ }: {
377
+ turn: SessionTurn;
378
+ index: number;
379
+ count: number;
380
+ pending: QueueMutationKind | null;
381
+ confirmingReplace: boolean;
382
+ keyboardDragging: boolean;
383
+ onHandleKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => void;
384
+ onMove: (index: number) => void;
385
+ onEdit: () => void;
386
+ onConfirmReplace: () => void;
387
+ onCancelReplace: () => void;
388
+ onSteer: () => void;
389
+ onDelete: () => void;
390
+ }) {
391
+ const sortable = useSortable({
392
+ id: turn.id,
393
+ disabled: pending !== null || keyboardDragging,
394
+ });
395
+ return (
396
+ <li
397
+ data-queue-turn-id={turn.id}
398
+ ref={sortable.setNodeRef}
399
+ style={{
400
+ transform: CSS.Transform.toString(sortable.transform),
401
+ transition: sortable.transition,
402
+ }}
403
+ className={`bg-surface ${sortable.isDragging || keyboardDragging ? "relative z-10 shadow-lg ring-1 ring-brand/40" : ""}`}
404
+ >
405
+ <div className="flex min-w-0 items-start gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
406
+ <button
407
+ data-queue-handle
408
+ ref={sortable.setActivatorNodeRef}
409
+ type="button"
410
+ {...sortable.attributes}
411
+ {...sortable.listeners}
412
+ onKeyDown={onHandleKeyDown}
413
+ disabled={pending !== null}
414
+ className="mt-0.5 inline-flex size-7 shrink-0 touch-none items-center justify-center rounded-md text-fg-subtle hover:bg-surface-2 hover:text-fg focus-visible:ring-2 focus-visible:ring-ring/40 pointer-coarse:size-11"
415
+ aria-label={`Reorder queued prompt ${index + 1}`}
416
+ title="Drag to reorder. Press Space, arrows, then Space to drop."
417
+ >
418
+ <GripVerticalIcon className="size-3.5" />
419
+ </button>
420
+ <span className="mt-1 shrink-0 font-mono text-2xs text-fg-subtle">{index + 1}</span>
421
+ <div className="min-w-0 flex-1">
422
+ <p className="whitespace-pre-wrap break-words text-xs leading-5 text-fg">{turn.prompt}</p>
423
+ <div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-2xs text-fg-subtle">
424
+ {turn.resources.length > 0 ? (
425
+ <span>
426
+ {turn.resources.length} resource{turn.resources.length === 1 ? "" : "s"}
427
+ </span>
428
+ ) : null}
429
+ {turn.tools.length > 0 ? (
430
+ <span>
431
+ {turn.tools.length} tool{turn.tools.length === 1 ? "" : "s"}
432
+ </span>
433
+ ) : null}
434
+ <span>{turn.model}</span>
435
+ <span>{turn.reasoningEffort}</span>
436
+ </div>
437
+ </div>
438
+ <button
439
+ type="button"
440
+ disabled={pending !== null}
441
+ onClick={onSteer}
442
+ aria-label={`Steer queued prompt ${index + 1}`}
443
+ title="Make this the next direction"
444
+ className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-xs font-medium outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 pointer-coarse:min-h-11"
445
+ >
446
+ {pending === "steer" ? (
447
+ <Loader2Icon className="size-3.5 animate-spin" />
448
+ ) : (
449
+ <ZapIcon className="size-3.5" />
450
+ )}
451
+ <span className="hidden sm:inline">Steer</span>
452
+ </button>
453
+ <button
454
+ type="button"
455
+ disabled={pending !== null}
456
+ onClick={onDelete}
457
+ aria-label={`Delete queued prompt ${index + 1}`}
458
+ title="Delete this queued prompt"
459
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-surface-2 hover:text-status-failed focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 pointer-coarse:size-11"
460
+ >
461
+ {pending === "delete" ? (
462
+ <Loader2Icon className="size-3.5 animate-spin" />
463
+ ) : (
464
+ <Trash2Icon className="size-3.5" />
465
+ )}
466
+ </button>
467
+ <DropdownMenu.Root>
468
+ <DropdownMenu.Trigger asChild>
469
+ <button
470
+ type="button"
471
+ disabled={pending !== null}
472
+ aria-label={`More actions for queued prompt ${index + 1}`}
473
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 pointer-coarse:size-11"
474
+ >
475
+ {pending && pending !== "steer" && pending !== "delete" ? (
476
+ <Loader2Icon className="size-3.5 animate-spin" />
477
+ ) : (
478
+ <EllipsisIcon className="size-3.5" />
479
+ )}
480
+ </button>
481
+ </DropdownMenu.Trigger>
482
+ <DropdownMenu.Portal>
483
+ <DropdownMenu.Content
484
+ align="end"
485
+ sideOffset={4}
486
+ className="z-50 w-48 rounded-md border border-border bg-surface p-1 text-xs text-fg shadow-lg"
487
+ >
488
+ <DropdownMenu.Item
489
+ className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2"
490
+ onSelect={onEdit}
491
+ >
492
+ <PencilIcon className="size-3.5" /> Edit in composer
493
+ </DropdownMenu.Item>
494
+ <DropdownMenu.Separator className="my-1 h-px bg-border" />
495
+ <DropdownMenu.Item
496
+ className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50"
497
+ disabled={index === 0}
498
+ onSelect={() => onMove(0)}
499
+ >
500
+ <ArrowUpToLineIcon className="size-3.5" /> Move to top
501
+ </DropdownMenu.Item>
502
+ <DropdownMenu.Item
503
+ className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50"
504
+ disabled={index === 0}
505
+ onSelect={() => onMove(index - 1)}
506
+ >
507
+ Move up
508
+ </DropdownMenu.Item>
509
+ <DropdownMenu.Item
510
+ className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50"
511
+ disabled={index === count - 1}
512
+ onSelect={() => onMove(index + 1)}
513
+ >
514
+ Move down
515
+ </DropdownMenu.Item>
516
+ <DropdownMenu.Item
517
+ className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50"
518
+ disabled={index === count - 1}
519
+ onSelect={() => onMove(count - 1)}
520
+ >
521
+ <ArrowDownToLineIcon className="size-3.5" /> Move to bottom
522
+ </DropdownMenu.Item>
523
+ </DropdownMenu.Content>
524
+ </DropdownMenu.Portal>
525
+ </DropdownMenu.Root>
526
+ </div>
527
+ {confirmingReplace ? (
528
+ <div className="mx-3 mb-2 rounded-md border border-status-waiting/30 bg-status-waiting/10 p-2 text-xs text-fg">
529
+ <p>Your composer already has a draft. Replace it with this queued prompt?</p>
530
+ <p className="mt-0.5 text-fg-muted">
531
+ The current draft will be permanently discarded; this queued prompt is preserved until
532
+ you confirm.
533
+ </p>
534
+ <div className="mt-2 flex justify-end gap-1.5">
535
+ <button
536
+ type="button"
537
+ className="rounded-md px-2 py-1 font-medium hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring/40"
538
+ onClick={onCancelReplace}
539
+ >
540
+ Keep current draft
541
+ </button>
542
+ <button
543
+ type="button"
544
+ className="rounded-md bg-brand px-2 py-1 font-medium text-white hover:bg-brand/90 focus-visible:ring-2 focus-visible:ring-ring/40"
545
+ onClick={onConfirmReplace}
546
+ >
547
+ Replace and edit
548
+ </button>
549
+ </div>
550
+ </div>
551
+ ) : null}
552
+ </li>
553
+ );
554
+ }
555
+
556
+ const verticalOnly: Modifier = ({ transform }) => ({ ...transform, x: 0 });
557
+
558
+ function focusQueueTurn(turnId: string): void {
559
+ window.requestAnimationFrame(() => {
560
+ document
561
+ .querySelector<HTMLElement>(`[data-queue-turn-id="${turnId}"] [data-queue-handle]`)
562
+ ?.focus();
563
+ });
564
+ }
565
+
566
+ function focusAfterQueueRemoval(previousIndex: number): void {
567
+ window.requestAnimationFrame(() => {
568
+ const handles = document.querySelectorAll<HTMLElement>("[data-queue-handle]");
569
+ const nearest = handles[Math.min(previousIndex, Math.max(0, handles.length - 1))];
570
+ if (nearest) {
571
+ nearest.focus();
572
+ return;
573
+ }
574
+ document
575
+ .querySelector<HTMLTextAreaElement>('textarea[aria-label="Message the agent"]')
576
+ ?.focus();
577
+ });
578
+ }