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