@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -0,0 +1,1041 @@
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
+ Trash2Icon,
29
+ ZapIcon,
30
+ } from "lucide-react";
31
+ import {
32
+ useCallback,
33
+ useId,
34
+ useMemo,
35
+ useRef,
36
+ useState,
37
+ type KeyboardEvent as ReactKeyboardEvent,
38
+ } from "react";
39
+
40
+ import { DropdownMenu } from "radix-ui";
41
+ import type { ComposerState } from "../hooks/use-composer";
42
+ import type { QueueMutationKind, UseTurnQueueResult } from "../hooks/use-turn-queue";
43
+ import { QueueErrorAlert, QueueStoppingStatus } from "./queue-surface-state";
44
+
45
+ /** The sole human prompt queue: compact above Goal, Agents, and composer. */
46
+ type QueueSurfaceCommonProps = {
47
+ /** Focus the composer owned by this queue after checkout/removal. */
48
+ onRequestComposerFocus?: (() => void) | undefined;
49
+ };
50
+
51
+ export type QueueSurfaceProps =
52
+ | (QueueSurfaceCommonProps & {
53
+ queue: UseTurnQueueResult;
54
+ composer: ComposerState;
55
+ readOnly?: false | undefined;
56
+ })
57
+ | (QueueSurfaceCommonProps & {
58
+ queue: UseTurnQueueResult;
59
+ composer?: undefined;
60
+ readOnly: true;
61
+ });
62
+
63
+ /** @internal Pure policy seam for queue/composer embedding tests. */
64
+ export function queueComposerCheckoutEnabled(
65
+ composer: ComposerState | undefined,
66
+ readOnly: boolean,
67
+ ): boolean {
68
+ return !readOnly && composer !== undefined && composer.draftPersistence !== "disabled";
69
+ }
70
+
71
+ export function QueueSurface({
72
+ queue,
73
+ composer,
74
+ readOnly = false,
75
+ onRequestComposerFocus,
76
+ }: QueueSurfaceProps) {
77
+ const [open, setOpen] = useState(false);
78
+ const [replaceDraftFor, setReplaceDraftFor] = useState<string | null>(null);
79
+ const [announcement, setAnnouncement] = useState("");
80
+ const [draggedTurnId, setDraggedTurnId] = useState<string | null>(null);
81
+ const surfaceRef = useRef<HTMLDivElement | null>(null);
82
+ const [keyboardDrag, setKeyboardDrag] = useState<{
83
+ turnId: string;
84
+ projectedIndex: number;
85
+ } | null>(null);
86
+ const count = queue.queue.length;
87
+ const canEditInComposer = queueComposerCheckoutEnabled(composer, readOnly);
88
+ const collapsedPreview = useMemo(
89
+ () => queuePromptPreview(queue.queue[0]?.prompt ?? "", QUEUE_COLLAPSED_PREVIEW_CHARACTERS),
90
+ [queue.queue],
91
+ );
92
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
93
+
94
+ const displayedQueue = useMemo(() => {
95
+ if (!keyboardDrag) return queue.queue;
96
+ const oldIndex = queue.queue.findIndex((turn) => turn.id === keyboardDrag.turnId);
97
+ if (oldIndex < 0) return queue.queue;
98
+ return arrayMove(queue.queue, oldIndex, keyboardDrag.projectedIndex);
99
+ }, [keyboardDrag, queue.queue]);
100
+
101
+ const ids = useMemo(() => displayedQueue.map((turn) => turn.id), [displayedQueue]);
102
+ const moveToIndex = useCallback(
103
+ async (turnId: string, nextIndex: number): Promise<void> => {
104
+ const oldIndex = queue.queue.findIndex((turn) => turn.id === turnId);
105
+ if (oldIndex < 0) return;
106
+ const boundedIndex = Math.max(0, Math.min(nextIndex, queue.queue.length - 1));
107
+ if (oldIndex === boundedIndex) return;
108
+ const ordered = arrayMove(queue.queue, oldIndex, boundedIndex);
109
+ const beforeTurnId = ordered[boundedIndex + 1]?.id ?? null;
110
+ const moved = await queue.moveTurn(turnId, beforeTurnId);
111
+ setAnnouncement(
112
+ moved
113
+ ? `Queued prompt moved to position ${boundedIndex + 1}.`
114
+ : "The queue changed before that prompt could be moved. Refreshed server order.",
115
+ );
116
+ if (moved) focusQueueTurn(surfaceRef.current, turnId);
117
+ },
118
+ [queue],
119
+ );
120
+
121
+ const onDragEnd = useCallback(
122
+ (event: DragEndEvent) => {
123
+ setDraggedTurnId(null);
124
+ const activeId = String(event.active.id);
125
+ const overId = event.over ? String(event.over.id) : null;
126
+ if (!overId || activeId === overId) {
127
+ setAnnouncement("Queued prompt returned to its original position.");
128
+ return;
129
+ }
130
+ const nextIndex = queue.queue.findIndex((turn) => turn.id === overId);
131
+ if (nextIndex >= 0) void moveToIndex(activeId, nextIndex);
132
+ },
133
+ [moveToIndex, queue.queue],
134
+ );
135
+
136
+ const onDragStart = useCallback(
137
+ (event: DragStartEvent) => {
138
+ setKeyboardDrag(null);
139
+ const turnId = String(event.active.id);
140
+ const position = queue.queue.findIndex((turn) => turn.id === turnId) + 1;
141
+ setDraggedTurnId(turnId);
142
+ setAnnouncement(`Dragging queued prompt ${position} of ${queue.queue.length}.`);
143
+ },
144
+ [queue.queue],
145
+ );
146
+
147
+ const onHandleKeyDown = useCallback(
148
+ (event: ReactKeyboardEvent<HTMLButtonElement>, turnId: string) => {
149
+ const canonicalIndex = queue.queue.findIndex((turn) => turn.id === turnId);
150
+ if (canonicalIndex < 0) return;
151
+
152
+ if (!keyboardDrag) {
153
+ if (event.key !== " ") return;
154
+ event.preventDefault();
155
+ setKeyboardDrag({ turnId, projectedIndex: canonicalIndex });
156
+ setAnnouncement(
157
+ `Lifted queued prompt ${canonicalIndex + 1} of ${count}. Use arrow keys to move it, then press Space to drop.`,
158
+ );
159
+ return;
160
+ }
161
+ if (keyboardDrag.turnId !== turnId) return;
162
+
163
+ if (event.key === "Escape") {
164
+ event.preventDefault();
165
+ setKeyboardDrag(null);
166
+ setAnnouncement("Queue reorder cancelled.");
167
+ return;
168
+ }
169
+ if (event.key === " ") {
170
+ event.preventDefault();
171
+ const targetIndex = keyboardDrag.projectedIndex;
172
+ setAnnouncement(`Moving queued prompt to position ${targetIndex + 1}.`);
173
+ void moveToIndex(turnId, targetIndex).finally(() => setKeyboardDrag(null));
174
+ return;
175
+ }
176
+
177
+ const direction = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
178
+ const edge = event.key === "Home" ? 0 : event.key === "End" ? count - 1 : null;
179
+ if (direction === 0 && edge === null) return;
180
+ event.preventDefault();
181
+ const projectedIndex = Math.max(
182
+ 0,
183
+ Math.min(edge ?? keyboardDrag.projectedIndex + direction, count - 1),
184
+ );
185
+ setKeyboardDrag({ turnId, projectedIndex });
186
+ setAnnouncement(`Queued prompt projected to position ${projectedIndex + 1} of ${count}.`);
187
+ },
188
+ [count, keyboardDrag, moveToIndex, queue.queue],
189
+ );
190
+
191
+ const edit = useCallback(
192
+ async (turn: SessionTurn, replaceDraft: boolean) => {
193
+ if (!composer || !canEditInComposer) return;
194
+ const restored = await queue.editTurn(turn.id, {
195
+ expectedDraftRevision: composer.draftRevision,
196
+ replaceDraft,
197
+ });
198
+ if (!restored) return;
199
+ composer.applyDraft(restored);
200
+ setReplaceDraftFor(null);
201
+ setAnnouncement("Queued prompt moved back to the composer for editing.");
202
+ window.requestAnimationFrame(() => {
203
+ if (onRequestComposerFocus) {
204
+ onRequestComposerFocus();
205
+ return;
206
+ }
207
+ const input = document.querySelector<HTMLTextAreaElement>(
208
+ 'textarea[aria-label="Message the agent"]',
209
+ );
210
+ input?.scrollIntoView({ block: "nearest", behavior: "smooth" });
211
+ input?.focus();
212
+ });
213
+ },
214
+ [canEditInComposer, composer, onRequestComposerFocus, queue],
215
+ );
216
+
217
+ const requestEdit = useCallback(
218
+ (turn: SessionTurn) => {
219
+ if (!composer || !canEditInComposer) return;
220
+ const draftDirty =
221
+ composer.value.length > 0 ||
222
+ composer.restoredResources.length > 0 ||
223
+ (composer.draft?.tools.length ?? 0) > 0 ||
224
+ (composer.draft?.sourceTurnId !== null && composer.draft?.sourceTurnId !== undefined);
225
+ if (draftDirty) {
226
+ setReplaceDraftFor(turn.id);
227
+ } else {
228
+ void edit(turn, false);
229
+ }
230
+ },
231
+ [canEditInComposer, composer, edit],
232
+ );
233
+
234
+ if (count === 0 && !queue.stoppingPreviousAttempt && !queue.error && !queue.mutationError)
235
+ return null;
236
+
237
+ return (
238
+ <div
239
+ ref={surfaceRef}
240
+ className="mx-auto mb-2 w-full max-w-3xl shrink-0 px-4 sm:px-6"
241
+ data-testid="queue-surface"
242
+ >
243
+ <div className="overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm">
244
+ {queue.stoppingPreviousAttempt ? <QueueStoppingStatus queue={queue} dividerAfter /> : null}
245
+ <button
246
+ type="button"
247
+ className="flex w-full min-w-0 flex-wrap 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-[44px]"
248
+ onClick={() => setOpen((value) => !value)}
249
+ aria-description={!open && count > 0 ? collapsedPreview.summary : undefined}
250
+ aria-expanded={open}
251
+ aria-label={`${count} queued prompt${count === 1 ? "" : "s"}${readOnly ? " Read-only" : ""}`}
252
+ >
253
+ <ChevronDownIcon
254
+ aria-hidden="true"
255
+ className={`size-3.5 shrink-0 text-fg-subtle transition-transform ${open ? "rotate-180" : ""}`}
256
+ />
257
+ <span className="text-xs font-medium text-fg">
258
+ {count} queued prompt{count === 1 ? "" : "s"}
259
+ </span>
260
+ {readOnly ? (
261
+ <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-2xs text-fg-subtle">
262
+ Read-only
263
+ </span>
264
+ ) : null}
265
+ {!open && count > 0 ? (
266
+ <span
267
+ aria-hidden="true"
268
+ className={`max-w-full truncate text-fg-muted ${
269
+ collapsedPreview.collapsedVisual === collapsedPreview.summary
270
+ ? "min-w-0 flex-1 text-xs"
271
+ : "shrink-0 text-[10px]"
272
+ }`}
273
+ data-testid="queue-collapsed-preview"
274
+ dir="auto"
275
+ >
276
+ {collapsedPreview.collapsedVisual}
277
+ </span>
278
+ ) : null}
279
+ {queue.loading ? <Loader2Icon className="ml-auto size-3.5 animate-spin" /> : null}
280
+ </button>
281
+
282
+ {open && count > 0 && readOnly ? (
283
+ <ol
284
+ className="max-h-[min(30rem,60dvh)] divide-y divide-border overflow-y-auto overscroll-contain border-t border-border"
285
+ aria-label="Queued prompts"
286
+ data-testid="queue-list"
287
+ >
288
+ {queue.queue.map((turn, index) => (
289
+ <ReadOnlyQueueRow
290
+ key={turn.id}
291
+ turn={turn}
292
+ index={index}
293
+ onDisclosureChange={(expanded) =>
294
+ setAnnouncement(
295
+ `Full content for queued prompt ${index + 1} ${expanded ? "shown" : "hidden"}.`,
296
+ )
297
+ }
298
+ />
299
+ ))}
300
+ </ol>
301
+ ) : null}
302
+
303
+ {open && count > 0 && !readOnly ? (
304
+ <DndContext
305
+ sensors={sensors}
306
+ collisionDetection={closestCenter}
307
+ modifiers={[verticalOnly]}
308
+ onDragStart={onDragStart}
309
+ onDragCancel={() => {
310
+ setDraggedTurnId(null);
311
+ setAnnouncement("Queue reorder cancelled.");
312
+ }}
313
+ onDragEnd={onDragEnd}
314
+ >
315
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
316
+ <ol
317
+ className="max-h-[min(30rem,60dvh)] divide-y divide-border overflow-y-auto overscroll-contain border-t border-border"
318
+ aria-label="Queued prompts"
319
+ data-testid="queue-list"
320
+ >
321
+ {displayedQueue.map((turn, index) => (
322
+ <SortableQueueRow
323
+ key={turn.id}
324
+ turn={turn}
325
+ index={index}
326
+ count={count}
327
+ pending={queue.mutationFor(turn.id)}
328
+ confirmingReplace={replaceDraftFor === turn.id}
329
+ keyboardDragging={keyboardDrag?.turnId === turn.id}
330
+ onHandleKeyDown={(event) => onHandleKeyDown(event, turn.id)}
331
+ onMove={(nextIndex) => void moveToIndex(turn.id, nextIndex)}
332
+ onEdit={canEditInComposer ? () => requestEdit(turn) : undefined}
333
+ onConfirmReplace={() => void edit(turn, true)}
334
+ onCancelReplace={() => setReplaceDraftFor(null)}
335
+ onSteer={() => {
336
+ void queue.steerTurn(turn.id).then((steered) => {
337
+ setAnnouncement(
338
+ steered
339
+ ? "Queued prompt is now the next direction."
340
+ : "That prompt changed before it could be steered.",
341
+ );
342
+ if (steered) {
343
+ focusAfterQueueRemoval(surfaceRef.current, index, onRequestComposerFocus);
344
+ }
345
+ });
346
+ }}
347
+ onDelete={() => {
348
+ void queue.removeTurn(turn.id).then((removed) => {
349
+ setAnnouncement(
350
+ removed
351
+ ? "Queued prompt deleted."
352
+ : "That prompt changed before it could be deleted.",
353
+ );
354
+ if (removed) {
355
+ focusAfterQueueRemoval(surfaceRef.current, index, onRequestComposerFocus);
356
+ }
357
+ });
358
+ }}
359
+ onDisclosureChange={(expanded) =>
360
+ setAnnouncement(
361
+ `Full content for queued prompt ${index + 1} ${expanded ? "shown" : "hidden"}.`,
362
+ )
363
+ }
364
+ />
365
+ ))}
366
+ </ol>
367
+ </SortableContext>
368
+ <DragOverlay modifiers={[verticalOnly]}>
369
+ {draggedTurnId ? (
370
+ <div
371
+ className="max-h-20 w-[min(36rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] overflow-hidden rounded-md border border-brand/40 bg-surface px-3 py-2 text-xs text-fg shadow-lg"
372
+ data-testid="queue-drag-overlay"
373
+ >
374
+ <p
375
+ aria-hidden="true"
376
+ className="line-clamp-3 break-all whitespace-pre-wrap [unicode-bidi:plaintext]"
377
+ dir="auto"
378
+ >
379
+ {
380
+ queuePromptPreview(
381
+ queue.queue.find((turn) => turn.id === draggedTurnId)?.prompt ?? "",
382
+ QUEUE_ROW_PREVIEW_CHARACTERS,
383
+ ).summary
384
+ }
385
+ </p>
386
+ </div>
387
+ ) : null}
388
+ </DragOverlay>
389
+ </DndContext>
390
+ ) : null}
391
+
392
+ {queue.error || queue.mutationError ? (
393
+ <QueueErrorAlert queue={queue} dividerBefore />
394
+ ) : null}
395
+ </div>
396
+ <p className="sr-only" aria-live="polite" aria-atomic="true">
397
+ {announcement}
398
+ </p>
399
+ </div>
400
+ );
401
+ }
402
+
403
+ const QUEUE_ROW_PREVIEW_CHARACTERS = 360;
404
+ const QUEUE_COLLAPSED_PREVIEW_CHARACTERS = 180;
405
+ const QUEUE_PREVIEW_GRAPHEME_CONTEXT_CHARACTERS = 32;
406
+ const QUEUE_PREVIEW_REFERENCE_SAMPLE_CHARACTERS = 32;
407
+ const QUEUE_VISIBLE_END_IDENTITY_CHARACTERS = 18;
408
+ const QUEUE_PREVIEW_SEPARATOR = " … ";
409
+ const queuePreviewSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
410
+ const queuePromptNonRenderingCodePoint =
411
+ /[\p{White_Space}\p{Control}\p{Default_Ignorable_Code_Point}]/u;
412
+
413
+ type BoundedPromptSample = {
414
+ value: string;
415
+ characters: number;
416
+ truncated: boolean;
417
+ };
418
+
419
+ type QueuePromptPreview = {
420
+ summary: string;
421
+ collapsedVisual: string;
422
+ visibleStart: string;
423
+ visibleIdentity: string | null;
424
+ visibleIdentityLabel: "End" | "Safe boundary" | null;
425
+ isFallback: boolean;
426
+ };
427
+
428
+ /**
429
+ * Build a bounded head/tail summary of an arbitrary queued prompt. Sampling
430
+ * both ends distinguishes prompts with equal long prefixes. Each sampled edge
431
+ * has a small amount of grapheme context so ordinary emoji/combining sequences
432
+ * can be retained whole. A cluster that exceeds that context is omitted rather
433
+ * than fragmented, and malformed UTF-16 is replaced only in the summary. The
434
+ * durable prompt remains exact and no operation scans or copies it in full.
435
+ */
436
+ function queuePromptPreview(prompt: string, maxCharacters: number): QueuePromptPreview {
437
+ const wholePromptProbe = samplePromptStart(prompt, maxCharacters + 1);
438
+ if (!wholePromptProbe.truncated && wholePromptProbe.characters <= maxCharacters) {
439
+ const summary = replaceLoneSurrogates(wholePromptProbe.value);
440
+ return hasVisiblePromptContent(summary)
441
+ ? {
442
+ summary,
443
+ collapsedVisual: summary,
444
+ visibleStart: summary,
445
+ visibleIdentity: null,
446
+ visibleIdentityLabel: null,
447
+ isFallback: false,
448
+ }
449
+ : fallbackPromptPreview(prompt.length, wholePromptProbe.value, wholePromptProbe.value);
450
+ }
451
+
452
+ const suffixCharacters = Math.min(Math.floor(maxCharacters / 3), 120);
453
+ const prefixCharacters =
454
+ maxCharacters - codePointLength(QUEUE_PREVIEW_SEPARATOR) - suffixCharacters;
455
+ const prefixSample = samplePromptStart(
456
+ prompt,
457
+ prefixCharacters + QUEUE_PREVIEW_GRAPHEME_CONTEXT_CHARACTERS,
458
+ );
459
+ const suffixSample = samplePromptEnd(
460
+ prompt,
461
+ suffixCharacters + QUEUE_PREVIEW_GRAPHEME_CONTEXT_CHARACTERS,
462
+ );
463
+ const prefixSegments = segmentPromptSample(prefixSample.value);
464
+ const suffixSegments = segmentPromptSample(suffixSample.value);
465
+
466
+ // A cluster at a truncated sampling edge may continue outside the sample.
467
+ // Prefix sampling starts at the true source start, so only its last segment
468
+ // is ambiguous. Suffix sampling ends at the true source end, so its first is.
469
+ if (prefixSample.truncated) prefixSegments.pop();
470
+ if (suffixSample.truncated) {
471
+ let ambiguousSegment = suffixSegments.shift();
472
+ // A locally segmented leading fragment ending in ZWJ can join the next
473
+ // pictographic segment when omitted left context supplies its base. Keep
474
+ // backing off until that uncertainty no longer propagates to the right.
475
+ while (ambiguousSegment?.endsWith("\u200d") && suffixSegments.length > 0) {
476
+ ambiguousSegment = suffixSegments.shift();
477
+ }
478
+ // Regional Indicator pairing depends on the parity of the preceding run.
479
+ // If that run reaches the unknown sample boundary, omit all of its visible
480
+ // leading segments rather than potentially recombining halves of flags.
481
+ while (suffixSegments[0] && startsWithRegionalIndicator(suffixSegments[0])) {
482
+ suffixSegments.shift();
483
+ }
484
+ }
485
+
486
+ const prefix = takeWholeGraphemesFromStart(prefixSegments, prefixCharacters);
487
+ const suffix = takeWholeGraphemesFromEnd(suffixSegments, suffixCharacters);
488
+ const reference = boundedPromptSampleReference(
489
+ prompt.length,
490
+ prefixSample.value,
491
+ suffixSample.value,
492
+ );
493
+
494
+ if (!hasVisiblePromptContent(prefix) && !hasVisiblePromptContent(suffix)) {
495
+ return fallbackPromptPreview(prompt.length, prefixSample.value, suffixSample.value);
496
+ }
497
+
498
+ const safeSuffix = hasVisiblePromptContent(suffix)
499
+ ? suffix
500
+ : promptPreviewFallbackLabel(reference);
501
+ const summary = `${prefix}${QUEUE_PREVIEW_SEPARATOR}${safeSuffix}`;
502
+ if (!hasVisiblePromptContent(prefix)) {
503
+ return {
504
+ summary,
505
+ collapsedVisual: summary,
506
+ visibleStart: safeSuffix,
507
+ visibleIdentity: null,
508
+ visibleIdentityLabel: null,
509
+ isFallback: false,
510
+ };
511
+ }
512
+
513
+ const endIdentity = takeWholeGraphemesFromEnd(
514
+ suffixSegments,
515
+ QUEUE_VISIBLE_END_IDENTITY_CHARACTERS,
516
+ );
517
+ return {
518
+ summary,
519
+ collapsedVisual: summary,
520
+ visibleStart: prefix,
521
+ visibleIdentity: hasVisiblePromptContent(endIdentity) ? endIdentity : `ref ${reference}`,
522
+ visibleIdentityLabel: hasVisiblePromptContent(endIdentity) ? "End" : "Safe boundary",
523
+ isFallback: false,
524
+ };
525
+ }
526
+
527
+ function fallbackPromptPreview(
528
+ promptLength: number,
529
+ startSample: string,
530
+ endSample: string,
531
+ ): QueuePromptPreview {
532
+ const reference = boundedPromptSampleReference(promptLength, startSample, endSample);
533
+ const summary = promptPreviewFallbackLabel(reference);
534
+ return {
535
+ summary,
536
+ collapsedVisual: `Omitted · ${reference}`,
537
+ // The complete fallback remains the canonical accessible summary. Narrow
538
+ // collapsed/row layouts paint the bounded reference in a compact truthful
539
+ // form so ellipsis cannot hide the only identifying portion.
540
+ visibleStart: `Omitted · ${reference}`,
541
+ visibleIdentity: null,
542
+ visibleIdentityLabel: null,
543
+ isFallback: true,
544
+ };
545
+ }
546
+
547
+ function promptPreviewFallbackLabel(reference: string): string {
548
+ return `Content omitted at safe boundary · ref ${reference}`;
549
+ }
550
+
551
+ function boundedPromptSampleReference(
552
+ promptLength: number,
553
+ startSample: string,
554
+ endSample: string,
555
+ ): string {
556
+ const head = samplePromptStart(startSample, QUEUE_PREVIEW_REFERENCE_SAMPLE_CHARACTERS).value;
557
+ const tail = samplePromptEnd(endSample, QUEUE_PREVIEW_REFERENCE_SAMPLE_CHARACTERS).value;
558
+ let hash = 0x811c9dc5;
559
+ for (const value of [String(promptLength), head, tail]) {
560
+ for (let index = 0; index < value.length; index += 1) {
561
+ hash ^= value.charCodeAt(index);
562
+ hash = Math.imul(hash, 0x01000193);
563
+ }
564
+ hash ^= 0xffff;
565
+ hash = Math.imul(hash, 0x01000193);
566
+ }
567
+ return (hash >>> 0).toString(16).padStart(8, "0").toUpperCase();
568
+ }
569
+
570
+ function hasVisiblePromptContent(value: string): boolean {
571
+ // Callers pass only the already-bounded whole/head/tail preview samples.
572
+ // Default-ignorables and controls can survive trim() while painting no row
573
+ // identity at all (for example ZWJ, VS16, bidi controls, and tag characters).
574
+ for (const character of value) {
575
+ if (!queuePromptNonRenderingCodePoint.test(character)) return true;
576
+ }
577
+ return false;
578
+ }
579
+
580
+ function samplePromptStart(prompt: string, maxCharacters: number): BoundedPromptSample {
581
+ let end = 0;
582
+ let characters = 0;
583
+ while (end < prompt.length && characters < maxCharacters) {
584
+ const first = prompt.charCodeAt(end);
585
+ end +=
586
+ isHighSurrogate(first) &&
587
+ end + 1 < prompt.length &&
588
+ isLowSurrogate(prompt.charCodeAt(end + 1))
589
+ ? 2
590
+ : 1;
591
+ characters += 1;
592
+ }
593
+ return { value: prompt.slice(0, end), characters, truncated: end < prompt.length };
594
+ }
595
+
596
+ function samplePromptEnd(prompt: string, maxCharacters: number): BoundedPromptSample {
597
+ let start = prompt.length;
598
+ let characters = 0;
599
+ while (start > 0 && characters < maxCharacters) {
600
+ start -= 1;
601
+ if (
602
+ isLowSurrogate(prompt.charCodeAt(start)) &&
603
+ start > 0 &&
604
+ isHighSurrogate(prompt.charCodeAt(start - 1))
605
+ ) {
606
+ start -= 1;
607
+ }
608
+ characters += 1;
609
+ }
610
+ return { value: prompt.slice(start), characters, truncated: start > 0 };
611
+ }
612
+
613
+ function segmentPromptSample(sample: string): string[] {
614
+ return Array.from(
615
+ queuePreviewSegmenter.segment(replaceLoneSurrogates(sample)),
616
+ ({ segment }) => segment,
617
+ );
618
+ }
619
+
620
+ function replaceLoneSurrogates(value: string): string {
621
+ let sanitized = "";
622
+ for (let index = 0; index < value.length; index += 1) {
623
+ const current = value.charCodeAt(index);
624
+ if (
625
+ isHighSurrogate(current) &&
626
+ index + 1 < value.length &&
627
+ isLowSurrogate(value.charCodeAt(index + 1))
628
+ ) {
629
+ sanitized += value.slice(index, index + 2);
630
+ index += 1;
631
+ } else if (isHighSurrogate(current) || isLowSurrogate(current)) {
632
+ sanitized += "�";
633
+ } else {
634
+ sanitized += value[index];
635
+ }
636
+ }
637
+ return sanitized;
638
+ }
639
+
640
+ function takeWholeGraphemesFromStart(segments: string[], maxCharacters: number): string {
641
+ const selected: string[] = [];
642
+ let characters = 0;
643
+ for (const segment of segments) {
644
+ const segmentCharacters = codePointLength(segment);
645
+ if (characters + segmentCharacters > maxCharacters) break;
646
+ selected.push(segment);
647
+ characters += segmentCharacters;
648
+ }
649
+ while (selected.at(-1)?.trim().length === 0) selected.pop();
650
+ return selected.join("");
651
+ }
652
+
653
+ function takeWholeGraphemesFromEnd(segments: string[], maxCharacters: number): string {
654
+ const selected: string[] = [];
655
+ let characters = 0;
656
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
657
+ const segment = segments[index];
658
+ if (!segment) continue;
659
+ const segmentCharacters = codePointLength(segment);
660
+ if (characters + segmentCharacters > maxCharacters) break;
661
+ selected.unshift(segment);
662
+ characters += segmentCharacters;
663
+ }
664
+ while (selected[0]?.trim().length === 0) selected.shift();
665
+ while (selected.at(-1)?.trim().length === 0) selected.pop();
666
+ return selected.join("");
667
+ }
668
+
669
+ function codePointLength(value: string): number {
670
+ let characters = 0;
671
+ for (const _character of value) characters += 1;
672
+ return characters;
673
+ }
674
+
675
+ function startsWithRegionalIndicator(value: string): boolean {
676
+ const codePoint = value.codePointAt(0);
677
+ return codePoint !== undefined && codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff;
678
+ }
679
+
680
+ function isHighSurrogate(codeUnit: number): boolean {
681
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
682
+ }
683
+
684
+ function isLowSurrogate(codeUnit: number): boolean {
685
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
686
+ }
687
+
688
+ function QueuePrompt({
689
+ prompt,
690
+ index,
691
+ onDisclosureChange,
692
+ }: {
693
+ prompt: string;
694
+ index: number;
695
+ onDisclosureChange: (expanded: boolean) => void;
696
+ }) {
697
+ const [expanded, setExpanded] = useState(false);
698
+ const fullContentId = useId();
699
+ const preview = useMemo(() => queuePromptPreview(prompt, QUEUE_ROW_PREVIEW_CHARACTERS), [prompt]);
700
+
701
+ return (
702
+ <div className="w-full min-w-0 max-w-full">
703
+ <div
704
+ aria-label={`Queued prompt ${index + 1} summary: ${preview.summary}`}
705
+ className="max-w-full overflow-hidden text-xs leading-5 text-fg"
706
+ data-testid={`queue-prompt-preview-${index + 1}`}
707
+ dir="auto"
708
+ role="note"
709
+ >
710
+ <span aria-hidden="true" className="flex min-w-0 max-w-full items-baseline gap-2 sm:block">
711
+ <span
712
+ className={`${preview.isFallback ? "" : "line-clamp-1 sm:line-clamp-2"} min-w-0 flex-1 whitespace-pre-wrap break-all [unicode-bidi:plaintext]`}
713
+ data-testid={`queue-prompt-start-${index + 1}`}
714
+ dir="auto"
715
+ >
716
+ {preview.visibleStart}
717
+ </span>
718
+ {preview.visibleIdentity && preview.visibleIdentityLabel ? (
719
+ <span
720
+ className="flex min-w-0 max-w-[70%] shrink-0 items-center gap-1 text-2xs leading-4 text-fg-muted sm:mt-0.5 sm:max-w-full"
721
+ data-testid={`queue-prompt-identity-row-${index + 1}`}
722
+ >
723
+ <span className="shrink-0 font-medium text-fg-subtle">
724
+ {preview.visibleIdentityLabel}
725
+ </span>
726
+ <span
727
+ className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono [unicode-bidi:plaintext]"
728
+ data-testid={`queue-prompt-identity-${index + 1}`}
729
+ dir="auto"
730
+ >
731
+ {preview.visibleIdentity}
732
+ </span>
733
+ </span>
734
+ ) : null}
735
+ </span>
736
+ </div>
737
+ <button
738
+ type="button"
739
+ aria-controls={fullContentId}
740
+ aria-expanded={expanded}
741
+ aria-label={`${expanded ? "Hide" : "Show"} full content for queued prompt ${index + 1}`}
742
+ className="mt-1 inline-flex min-h-7 min-w-0 max-w-full items-center gap-1 whitespace-normal rounded-md text-left text-2xs font-medium text-fg-muted outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-ring/40 pointer-coarse:min-h-[44px]"
743
+ data-testid={`queue-prompt-disclosure-${index + 1}`}
744
+ onClick={() => {
745
+ const next = !expanded;
746
+ setExpanded(next);
747
+ onDisclosureChange(next);
748
+ }}
749
+ >
750
+ <ChevronDownIcon
751
+ aria-hidden="true"
752
+ className={`size-3 shrink-0 transition-transform ${expanded ? "rotate-180" : ""}`}
753
+ />
754
+ {expanded ? "Hide full prompt" : "View full prompt"}
755
+ </button>
756
+ {expanded ? (
757
+ <pre
758
+ id={fullContentId}
759
+ role="region"
760
+ aria-label={`Full content for queued prompt ${index + 1}`}
761
+ className="mt-1.5 max-h-64 w-full min-w-0 max-w-full overflow-auto overscroll-contain rounded-md border border-border bg-surface-2/60 p-2 whitespace-pre-wrap break-all font-mono text-xs leading-5 text-fg [unicode-bidi:plaintext]"
762
+ data-testid={`queue-prompt-full-${index + 1}`}
763
+ dir="auto"
764
+ tabIndex={0}
765
+ >
766
+ {prompt}
767
+ </pre>
768
+ ) : null}
769
+ </div>
770
+ );
771
+ }
772
+
773
+ function ReadOnlyQueueRow({
774
+ turn,
775
+ index,
776
+ onDisclosureChange,
777
+ }: {
778
+ turn: SessionTurn;
779
+ index: number;
780
+ onDisclosureChange: (expanded: boolean) => void;
781
+ }) {
782
+ return (
783
+ <li className="flex min-w-0 items-start gap-2 bg-surface px-3 py-2">
784
+ <span className="mt-1 shrink-0 font-mono text-2xs text-fg-subtle">{index + 1}</span>
785
+ <div className="min-w-0 flex-1">
786
+ <QueuePrompt prompt={turn.prompt} index={index} onDisclosureChange={onDisclosureChange} />
787
+ <div className="mt-1 flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap text-2xs text-fg-subtle">
788
+ {turn.resources.length > 0 ? (
789
+ <span className="shrink-0">
790
+ {turn.resources.length} resource{turn.resources.length === 1 ? "" : "s"}
791
+ </span>
792
+ ) : null}
793
+ {turn.tools.length > 0 ? (
794
+ <span className="shrink-0">
795
+ {turn.tools.length} tool{turn.tools.length === 1 ? "" : "s"}
796
+ </span>
797
+ ) : null}
798
+ <span className="min-w-0 truncate">{turn.model}</span>
799
+ <span className="shrink-0">{turn.reasoningEffort}</span>
800
+ </div>
801
+ </div>
802
+ </li>
803
+ );
804
+ }
805
+
806
+ function SortableQueueRow({
807
+ turn,
808
+ index,
809
+ count,
810
+ pending,
811
+ confirmingReplace,
812
+ keyboardDragging,
813
+ onHandleKeyDown,
814
+ onMove,
815
+ onEdit,
816
+ onConfirmReplace,
817
+ onCancelReplace,
818
+ onSteer,
819
+ onDelete,
820
+ onDisclosureChange,
821
+ }: {
822
+ turn: SessionTurn;
823
+ index: number;
824
+ count: number;
825
+ pending: QueueMutationKind | null;
826
+ confirmingReplace: boolean;
827
+ keyboardDragging: boolean;
828
+ onHandleKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => void;
829
+ onMove: (index: number) => void;
830
+ onEdit?: (() => void) | undefined;
831
+ onConfirmReplace: () => void;
832
+ onCancelReplace: () => void;
833
+ onSteer: () => void;
834
+ onDelete: () => void;
835
+ onDisclosureChange: (expanded: boolean) => void;
836
+ }) {
837
+ const sortable = useSortable({
838
+ id: turn.id,
839
+ disabled: pending !== null || keyboardDragging,
840
+ });
841
+ return (
842
+ <li
843
+ data-queue-turn-id={turn.id}
844
+ ref={sortable.setNodeRef}
845
+ style={{
846
+ transform: CSS.Transform.toString(sortable.transform),
847
+ transition: sortable.transition,
848
+ }}
849
+ className={`bg-surface ${sortable.isDragging || keyboardDragging ? "relative z-10 shadow-lg ring-1 ring-brand/40" : ""}`}
850
+ >
851
+ <div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-x-1.5 px-2 py-1.5 sm:grid-cols-[auto_auto_minmax(0,1fr)_auto] sm:gap-x-2 sm:px-3 sm:py-2">
852
+ <button
853
+ data-queue-handle
854
+ ref={sortable.setActivatorNodeRef}
855
+ type="button"
856
+ {...sortable.attributes}
857
+ {...sortable.listeners}
858
+ onKeyDown={onHandleKeyDown}
859
+ disabled={pending !== null}
860
+ className="col-start-1 row-start-1 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-[44px]"
861
+ aria-label={`Reorder queued prompt ${index + 1}`}
862
+ title="Drag to reorder. Press Space, arrows, then Space to drop."
863
+ >
864
+ <GripVerticalIcon className="size-3.5" />
865
+ </button>
866
+ <span className="col-start-2 row-start-1 mt-1 shrink-0 font-mono text-2xs text-fg-subtle">
867
+ {index + 1}
868
+ </span>
869
+ <div className="col-span-full row-start-2 min-w-0 sm:col-span-1 sm:col-start-3 sm:row-start-1">
870
+ <QueuePrompt prompt={turn.prompt} index={index} onDisclosureChange={onDisclosureChange} />
871
+ <div className="mt-1 flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap text-2xs text-fg-subtle">
872
+ {turn.resources.length > 0 ? (
873
+ <span className="shrink-0">
874
+ {turn.resources.length} resource{turn.resources.length === 1 ? "" : "s"}
875
+ </span>
876
+ ) : null}
877
+ {turn.tools.length > 0 ? (
878
+ <span className="shrink-0">
879
+ {turn.tools.length} tool{turn.tools.length === 1 ? "" : "s"}
880
+ </span>
881
+ ) : null}
882
+ <span className="min-w-0 truncate">{turn.model}</span>
883
+ <span className="shrink-0">{turn.reasoningEffort}</span>
884
+ </div>
885
+ </div>
886
+ <div className="col-span-full row-start-3 flex min-w-0 items-start justify-end gap-1.5 sm:col-span-1 sm:col-start-4 sm:row-start-1 sm:gap-2">
887
+ <button
888
+ type="button"
889
+ disabled={pending !== null}
890
+ onClick={onSteer}
891
+ aria-label={`Steer queued prompt ${index + 1}`}
892
+ title="Make this the next direction"
893
+ 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-[44px] pointer-coarse:min-w-[44px]"
894
+ >
895
+ {pending === "steer" ? (
896
+ <Loader2Icon className="size-3.5 animate-spin" />
897
+ ) : (
898
+ <ZapIcon className="size-3.5" />
899
+ )}
900
+ <span className="hidden sm:inline">Steer</span>
901
+ </button>
902
+ <button
903
+ type="button"
904
+ disabled={pending !== null}
905
+ onClick={onDelete}
906
+ aria-label={`Delete queued prompt ${index + 1}`}
907
+ title="Delete this queued prompt"
908
+ 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-[44px]"
909
+ >
910
+ {pending === "delete" ? (
911
+ <Loader2Icon className="size-3.5 animate-spin" />
912
+ ) : (
913
+ <Trash2Icon className="size-3.5" />
914
+ )}
915
+ </button>
916
+ <DropdownMenu.Root>
917
+ <DropdownMenu.Trigger asChild>
918
+ <button
919
+ type="button"
920
+ disabled={pending !== null}
921
+ aria-label={`More actions for queued prompt ${index + 1}`}
922
+ 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-[44px]"
923
+ >
924
+ {pending && pending !== "steer" && pending !== "delete" ? (
925
+ <Loader2Icon className="size-3.5 animate-spin" />
926
+ ) : (
927
+ <EllipsisIcon className="size-3.5" />
928
+ )}
929
+ </button>
930
+ </DropdownMenu.Trigger>
931
+ <DropdownMenu.Portal>
932
+ <DropdownMenu.Content
933
+ align="end"
934
+ sideOffset={4}
935
+ className="z-50 w-48 max-w-[calc(100vw-16px)] rounded-md border border-border bg-surface p-1 text-xs text-fg shadow-lg"
936
+ data-testid={`queue-actions-menu-${index + 1}`}
937
+ >
938
+ {onEdit ? (
939
+ <>
940
+ <DropdownMenu.Item
941
+ className="flex min-w-0 cursor-default items-center gap-2 whitespace-normal break-words rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 pointer-coarse:min-h-[44px]"
942
+ onSelect={onEdit}
943
+ >
944
+ <PencilIcon className="size-3.5" /> Edit in composer
945
+ </DropdownMenu.Item>
946
+ <DropdownMenu.Separator className="my-1 h-px bg-border" />
947
+ </>
948
+ ) : null}
949
+ <DropdownMenu.Item
950
+ className="flex min-w-0 cursor-default items-center gap-2 whitespace-normal break-words rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50 pointer-coarse:min-h-[44px]"
951
+ disabled={index === 0}
952
+ onSelect={() => onMove(0)}
953
+ >
954
+ <ArrowUpToLineIcon className="size-3.5" /> Move to top
955
+ </DropdownMenu.Item>
956
+ <DropdownMenu.Item
957
+ className="flex min-w-0 cursor-default items-center gap-2 whitespace-normal break-words rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50 pointer-coarse:min-h-[44px]"
958
+ disabled={index === 0}
959
+ onSelect={() => onMove(index - 1)}
960
+ >
961
+ Move up
962
+ </DropdownMenu.Item>
963
+ <DropdownMenu.Item
964
+ className="flex min-w-0 cursor-default items-center gap-2 whitespace-normal break-words rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50 pointer-coarse:min-h-[44px]"
965
+ disabled={index === count - 1}
966
+ onSelect={() => onMove(index + 1)}
967
+ >
968
+ Move down
969
+ </DropdownMenu.Item>
970
+ <DropdownMenu.Item
971
+ className="flex min-w-0 cursor-default items-center gap-2 whitespace-normal break-words rounded-sm px-2 py-1.5 outline-none focus:bg-surface-2 data-[disabled]:opacity-50 pointer-coarse:min-h-[44px]"
972
+ disabled={index === count - 1}
973
+ onSelect={() => onMove(count - 1)}
974
+ >
975
+ <ArrowDownToLineIcon className="size-3.5" /> Move to bottom
976
+ </DropdownMenu.Item>
977
+ </DropdownMenu.Content>
978
+ </DropdownMenu.Portal>
979
+ </DropdownMenu.Root>
980
+ </div>
981
+ </div>
982
+ {confirmingReplace ? (
983
+ <div className="mx-3 mb-2 rounded-md border border-status-waiting/30 bg-status-waiting/10 p-2 text-xs text-fg">
984
+ <p>Your composer already has a draft. Replace it with this queued prompt?</p>
985
+ <p className="mt-0.5 text-fg-muted">
986
+ The current draft will be permanently discarded; this queued prompt is preserved until
987
+ you confirm.
988
+ </p>
989
+ <div className="mt-2 flex justify-end gap-1.5">
990
+ <button
991
+ type="button"
992
+ className="rounded-md px-2 py-1 font-medium hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring/40"
993
+ onClick={onCancelReplace}
994
+ >
995
+ Keep current draft
996
+ </button>
997
+ <button
998
+ type="button"
999
+ 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"
1000
+ onClick={onConfirmReplace}
1001
+ >
1002
+ Replace and edit
1003
+ </button>
1004
+ </div>
1005
+ </div>
1006
+ ) : null}
1007
+ </li>
1008
+ );
1009
+ }
1010
+
1011
+ const verticalOnly: Modifier = ({ transform }) => ({ ...transform, x: 0 });
1012
+
1013
+ function focusQueueTurn(surface: HTMLElement | null, turnId: string): void {
1014
+ window.requestAnimationFrame(() => {
1015
+ surface
1016
+ ?.querySelector<HTMLElement>(`[data-queue-turn-id="${turnId}"] [data-queue-handle]`)
1017
+ ?.focus();
1018
+ });
1019
+ }
1020
+
1021
+ function focusAfterQueueRemoval(
1022
+ surface: HTMLElement | null,
1023
+ previousIndex: number,
1024
+ onRequestComposerFocus?: (() => void) | undefined,
1025
+ ): void {
1026
+ window.requestAnimationFrame(() => {
1027
+ const handles = surface?.querySelectorAll<HTMLElement>("[data-queue-handle]") ?? [];
1028
+ const nearest = handles[Math.min(previousIndex, Math.max(0, handles.length - 1))];
1029
+ if (nearest) {
1030
+ nearest.focus();
1031
+ return;
1032
+ }
1033
+ if (onRequestComposerFocus) {
1034
+ onRequestComposerFocus();
1035
+ return;
1036
+ }
1037
+ document
1038
+ .querySelector<HTMLTextAreaElement>('textarea[aria-label="Message the agent"]')
1039
+ ?.focus();
1040
+ });
1041
+ }