@opengeni/react 1.2.1-canary.37222965366001 → 1.4.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 (75) hide show
  1. package/README.md +52 -1
  2. package/dist/artifacts.js +2 -2
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/{chunk-ERGYJIBB.js → chunk-AWZLIMKX.js} +2 -2
  5. package/dist/{chunk-ERGYJIBB.js.map → chunk-AWZLIMKX.js.map} +1 -1
  6. package/dist/{chunk-FZCP527Y.js → chunk-CJUO7J5Z.js} +2 -2
  7. package/dist/{chunk-742CZ3L3.js → chunk-H5X55XH7.js} +16 -200
  8. package/dist/chunk-H5X55XH7.js.map +1 -0
  9. package/dist/{chunk-4OTO7KBK.js → chunk-LJ2UTJJE.js} +2737 -1580
  10. package/dist/chunk-LJ2UTJJE.js.map +1 -0
  11. package/dist/{chunk-CY7WK5FD.js → chunk-LN6TCXD3.js} +22 -1
  12. package/dist/chunk-LN6TCXD3.js.map +1 -0
  13. package/dist/{chunk-TMYGL6X5.js → chunk-MBMPHOMA.js} +71 -6
  14. package/dist/chunk-MBMPHOMA.js.map +1 -0
  15. package/dist/{chunk-RC6BZ44Q.js → chunk-OA6HFRQP.js} +2 -2
  16. package/dist/{chunk-X34BSY2R.js → chunk-WZMYFJD3.js} +242 -63
  17. package/dist/chunk-WZMYFJD3.js.map +1 -0
  18. package/dist/components/approval-surface.d.ts +11 -13
  19. package/dist/components/composer-transcription-control.d.ts +3 -0
  20. package/dist/components/open-geni-chat.d.ts +26 -7
  21. package/dist/components/session-conversation.d.ts +26 -3
  22. package/dist/components/session-list.d.ts +4 -1
  23. package/dist/components/session-proxy-scope.d.ts +16 -2
  24. package/dist/components/tool-action-review.d.ts +47 -0
  25. package/dist/components/tool-review-history.d.ts +25 -0
  26. package/dist/composer.js +1 -1
  27. package/dist/connect.js +1 -1
  28. package/dist/hooks/use-client-config-flags.d.ts +37 -0
  29. package/dist/index.d.ts +7 -4
  30. package/dist/index.js +27 -11
  31. package/dist/index.js.map +1 -1
  32. package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
  33. package/dist/realtime/realtime-control.d.ts +2 -0
  34. package/dist/realtime.js +25 -4
  35. package/dist/realtime.js.map +1 -1
  36. package/dist/session-capability-policy.d.ts +20 -1
  37. package/dist/session-ui.d.ts +4 -1
  38. package/dist/session-ui.js +12 -6
  39. package/dist/session-ui.js.map +1 -1
  40. package/dist/session.js +6 -6
  41. package/dist/timeline/index.d.ts +2 -0
  42. package/dist/timeline/retained-loaders.d.ts +27 -0
  43. package/package.json +3 -3
  44. package/src/components/approval-surface.tsx +368 -239
  45. package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
  46. package/src/components/composer-transcription-control.tsx +14 -0
  47. package/src/components/composer.tsx +1 -0
  48. package/src/components/open-geni-chat.tsx +245 -72
  49. package/src/components/session-conversation.tsx +243 -58
  50. package/src/components/session-list.tsx +9 -2
  51. package/src/components/session-proxy-scope.tsx +47 -7
  52. package/src/components/tool-action-review.tsx +649 -0
  53. package/src/components/tool-review-history.tsx +88 -0
  54. package/src/hooks/use-client-config-flags.ts +102 -0
  55. package/src/hooks/use-session-events.ts +19 -16
  56. package/src/hooks/use-transcription.ts +6 -0
  57. package/src/hooks/use-voice-input.ts +6 -0
  58. package/src/index.ts +18 -1
  59. package/src/realtime/realtime-control.tsx +28 -1
  60. package/src/session-capability-policy.ts +119 -6
  61. package/src/session-ui.ts +9 -1
  62. package/src/timeline/index.ts +10 -0
  63. package/src/timeline/retained-loaders.ts +86 -0
  64. package/src/timeline/tool-display-name.ts +5 -1
  65. package/src/timeline/tool-renderers.tsx +148 -8
  66. package/styles/compiled.css +79 -15
  67. package/styles/tokens.css +1 -1
  68. package/dist/chunk-4OTO7KBK.js.map +0 -1
  69. package/dist/chunk-742CZ3L3.js.map +0 -1
  70. package/dist/chunk-CY7WK5FD.js.map +0 -1
  71. package/dist/chunk-TMYGL6X5.js.map +0 -1
  72. package/dist/chunk-X34BSY2R.js.map +0 -1
  73. /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
  74. /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
  75. /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
@@ -0,0 +1,649 @@
1
+ import {
2
+ ArrowLeftIcon,
3
+ CheckIcon,
4
+ ChevronRightIcon,
5
+ CircleAlertIcon,
6
+ CircleSlashIcon,
7
+ LoaderCircleIcon,
8
+ ShieldCheckIcon,
9
+ } from "lucide-react";
10
+ import { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from "react";
11
+ import type { ToolActionReview, ToolReviewDetailsPage, ToolReviewStatus } from "@opengeni/sdk";
12
+ import { cn } from "../lib/cn";
13
+
14
+ export type ToolReviewDetailsLoader = (
15
+ review: ToolActionReview,
16
+ path: string,
17
+ offset: number,
18
+ ) => Promise<ToolReviewDetailsPage>;
19
+
20
+ const statusText: Record<ToolReviewStatus, string> = {
21
+ pending: "Approval needed",
22
+ approved: "Approved",
23
+ executing: "Approved · running",
24
+ completed: "Approved · done",
25
+ partial: "Partly done",
26
+ unknown: "Outcome unknown",
27
+ rejected: "Declined",
28
+ cancelled: "Cancelled",
29
+ expired: "Expired",
30
+ revoked: "Access unavailable",
31
+ blocked: "Blocked",
32
+ stale: "No longer valid",
33
+ failed: "Failed",
34
+ unavailable: "Details unavailable",
35
+ };
36
+ const statusDetail: Partial<Record<ToolReviewStatus, string>> = {
37
+ unknown: "It may have run. Check the result before trying again.",
38
+ rejected: "This action did not run.",
39
+ cancelled: "This action did not run.",
40
+ expired: "This action did not run. Ask the agent to prepare it again.",
41
+ revoked: "This action did not run. Reconnect the account, then ask the agent to try again.",
42
+ stale: "The tool or account changed after this was prepared, so it did not run.",
43
+ partial: "Some of it ran. Check the result before continuing.",
44
+ failed: "The action ran but returned an error.",
45
+ };
46
+ type Tone = "waiting" | "ok" | "failed" | "muted";
47
+ const statusTone: Record<ToolReviewStatus, Tone> = {
48
+ pending: "waiting",
49
+ approved: "ok",
50
+ executing: "ok",
51
+ completed: "ok",
52
+ partial: "failed",
53
+ unknown: "failed",
54
+ rejected: "muted",
55
+ cancelled: "muted",
56
+ expired: "muted",
57
+ revoked: "muted",
58
+ blocked: "muted",
59
+ stale: "muted",
60
+ failed: "failed",
61
+ unavailable: "muted",
62
+ };
63
+ const toneText: Record<Tone, string> = {
64
+ waiting: "text-og-status-waiting",
65
+ ok: "text-og-fg-muted",
66
+ failed: "text-og-status-failed",
67
+ muted: "text-og-fg-muted",
68
+ };
69
+ /** Fields that only identify the selection; the count, examples and details cover them. */
70
+ const selectionPaths = new Set([
71
+ "/messageIds",
72
+ "/messageId",
73
+ "/threadId",
74
+ "/addLabelIds",
75
+ "/removeLabelIds",
76
+ "/labelIds",
77
+ ]);
78
+ /** Shown when an older request's saved values are gone: approval is impossible, decline is not. */
79
+ export const UNRECOVERABLE_TEXT =
80
+ "The details of this older request couldn't be recovered, so it can't be approved. Decline it to let the agent continue.";
81
+ /** Long argument lists stay readable; the full set is one click away in details. */
82
+ const VISIBLE_FIELDS = 6;
83
+
84
+ function StatusIcon({ status, busy }: { status: ToolReviewStatus; busy?: boolean }) {
85
+ const className = "size-4";
86
+ if (busy || status === "executing" || status === "approved")
87
+ return <LoaderCircleIcon aria-hidden="true" className={cn(className, "animate-spin")} />;
88
+ if (status === "pending") return <ShieldCheckIcon aria-hidden="true" className={className} />;
89
+ if (status === "completed") return <CheckIcon aria-hidden="true" className={className} />;
90
+ if (statusTone[status] === "failed")
91
+ return <CircleAlertIcon aria-hidden="true" className={className} />;
92
+ return <CircleSlashIcon aria-hidden="true" className={className} />;
93
+ }
94
+
95
+ /** Card chrome shared by the review, its loading state and its load failure. */
96
+ export function ToolReviewFrame({
97
+ tone,
98
+ icon,
99
+ eyebrow,
100
+ title,
101
+ titleId,
102
+ subtitle,
103
+ children,
104
+ footer,
105
+ bare = false,
106
+ ...rest
107
+ }: {
108
+ tone: Tone;
109
+ icon: ReactNode;
110
+ eyebrow: ReactNode;
111
+ title: ReactNode;
112
+ titleId?: string | undefined;
113
+ subtitle?: ReactNode;
114
+ children?: ReactNode;
115
+ footer?: ReactNode;
116
+ bare?: boolean | undefined;
117
+ } & Omit<HTMLAttributes<HTMLElement>, "title">) {
118
+ const waiting = tone === "waiting";
119
+ return (
120
+ <article
121
+ aria-labelledby={titleId}
122
+ {...rest}
123
+ className={cn(
124
+ "min-w-0 text-og-fg",
125
+ !bare &&
126
+ (waiting
127
+ ? "overflow-hidden rounded-og-lg border border-og-status-waiting/35 bg-og-status-waiting/5 shadow-og-sm"
128
+ : "overflow-hidden rounded-og-lg border border-og-border bg-og-surface-1"),
129
+ rest.className,
130
+ )}
131
+ >
132
+ <header className={cn("flex min-w-0 items-start gap-3", bare ? "pb-1" : "px-4 py-3")}>
133
+ <span
134
+ className={cn(
135
+ "mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-og-md",
136
+ waiting
137
+ ? "bg-og-status-waiting/12 text-og-status-waiting"
138
+ : tone === "failed"
139
+ ? "bg-og-status-failed/10 text-og-status-failed"
140
+ : "bg-og-fg/5 text-og-fg-muted",
141
+ )}
142
+ >
143
+ {icon}
144
+ </span>
145
+ <div className="min-w-0 flex-1">
146
+ <p
147
+ className={cn("m-0 text-og-xs font-medium", toneText[tone])}
148
+ role="status"
149
+ aria-live="polite"
150
+ >
151
+ {eyebrow}
152
+ </p>
153
+ <h3
154
+ tabIndex={-1}
155
+ id={titleId}
156
+ className="m-0 mt-0.5 break-words text-og-md font-semibold leading-snug text-og-fg outline-hidden [overflow-wrap:anywhere]"
157
+ >
158
+ {title}
159
+ </h3>
160
+ {subtitle ? (
161
+ <p className="m-0 mt-0.5 break-words text-og-xs text-og-fg-muted [overflow-wrap:anywhere]">
162
+ {subtitle}
163
+ </p>
164
+ ) : null}
165
+ </div>
166
+ </header>
167
+ {children ? (
168
+ <div
169
+ className={cn(
170
+ "min-w-0",
171
+ bare ? "pt-2" : "px-4 pb-3",
172
+ !bare && "sm:pl-[3.75rem]", // Aligns with the title, past the icon.
173
+ )}
174
+ >
175
+ {children}
176
+ </div>
177
+ ) : null}
178
+ {footer ? (
179
+ <footer
180
+ className={cn(
181
+ "flex flex-wrap items-center gap-x-3 gap-y-2",
182
+ bare
183
+ ? "pt-3"
184
+ : waiting
185
+ ? "border-t border-og-status-waiting/20 px-4 py-3"
186
+ : "border-t border-og-border px-4 py-3",
187
+ )}
188
+ >
189
+ {footer}
190
+ </footer>
191
+ ) : null}
192
+ </article>
193
+ );
194
+ }
195
+
196
+ const linkButton =
197
+ "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40 inline-flex min-h-8 items-center gap-1 rounded-og-sm text-left text-og-sm text-og-fg underline decoration-og-border-strong underline-offset-4 hover:decoration-current [@media(pointer:coarse)]:min-h-11";
198
+
199
+ /** The facts of one saved action: consequences, selection and its key values. */
200
+ function ReviewFacts({
201
+ review,
202
+ onViewDetails,
203
+ showStatusDetail,
204
+ }: {
205
+ review: ToolActionReview;
206
+ onViewDetails?: ((path: string) => void) | undefined;
207
+ showStatusDetail: boolean;
208
+ }) {
209
+ const canOpen = Boolean(onViewDetails && review.detailsAvailable);
210
+ const selection =
211
+ review.selectionCount === undefined
212
+ ? undefined
213
+ : review.fields.find((field) =>
214
+ ["/messageIds", "/messageId", "/threadId"].includes(field.path),
215
+ );
216
+ const fields = selection
217
+ ? review.fields.filter((field) => !selectionPaths.has(field.path))
218
+ : review.fields;
219
+ const shown = fields.slice(0, VISIBLE_FIELDS);
220
+ const hiddenFields = fields.length - shown.length + review.moreFields;
221
+ // The title already names the main effect; list only the remaining ones.
222
+ const effects = review.effects.filter(
223
+ (effect) => effect !== review.approveLabel && !review.title.startsWith(effect),
224
+ );
225
+ const note =
226
+ (showStatusDetail
227
+ ? review.status === "blocked"
228
+ ? `${review.reason || "Your permission settings block this action."} It did not run.`
229
+ : statusDetail[review.status]
230
+ : undefined) ?? review.consequence;
231
+ const samples = review.samples ?? [];
232
+ const count = review.selectionCount ?? 0;
233
+ const noun =
234
+ review.selectionKind === "threads"
235
+ ? count === 1
236
+ ? "conversation"
237
+ : "conversations"
238
+ : count === 1
239
+ ? "message"
240
+ : "messages";
241
+ const hasContent =
242
+ note || effects.length || selection || shown.length || hiddenFields > 0 || samples.length;
243
+ if (!hasContent) return null;
244
+ return (
245
+ <div className="flex min-w-0 flex-col gap-3 text-og-sm">
246
+ {note ? <p className="m-0 leading-relaxed text-og-fg-muted">{note}</p> : null}
247
+ {effects.length > 0 ? (
248
+ <ul className="m-0 list-none space-y-1 p-0" aria-label="Also changes">
249
+ {effects.map((effect) => (
250
+ <li key={effect} className="flex items-start gap-2 text-og-fg">
251
+ <span
252
+ aria-hidden="true"
253
+ className="mt-[0.55em] size-1 shrink-0 rounded-full bg-og-fg-muted"
254
+ />
255
+ <span className="min-w-0 break-words">{effect}</span>
256
+ </li>
257
+ ))}
258
+ </ul>
259
+ ) : null}
260
+ {selection ? (
261
+ <div className="min-w-0">
262
+ {samples.length > 0 ? (
263
+ <>
264
+ <p className="m-0 mb-1 text-og-xs text-og-fg-muted">Includes</p>
265
+ <ul className="m-0 list-none divide-y divide-og-border rounded-og-md border border-og-border bg-og-surface-1 p-0">
266
+ {samples.map((sample) => (
267
+ <li key={sample.id} className="min-w-0 px-3 py-2">
268
+ <p className="m-0 truncate text-og-sm text-og-fg">{sample.title}</p>
269
+ {sample.subtitle ? (
270
+ <p className="m-0 mt-0.5 truncate text-og-xs text-og-fg-muted">
271
+ {sample.subtitle}
272
+ </p>
273
+ ) : null}
274
+ </li>
275
+ ))}
276
+ {count > samples.length ? (
277
+ <li className="px-3 py-2 text-og-xs text-og-fg-muted">
278
+ and {(count - samples.length).toLocaleString()} more
279
+ </li>
280
+ ) : null}
281
+ </ul>
282
+ </>
283
+ ) : null}
284
+ {canOpen && count > 0 ? (
285
+ <button
286
+ type="button"
287
+ className={cn(linkButton, samples.length > 0 && "mt-1")}
288
+ data-review-path={selection.path}
289
+ onClick={() => onViewDetails!(selection.path)}
290
+ >
291
+ {count === 1 ? `View the ${noun}` : `View all ${count.toLocaleString()} ${noun}`}
292
+ <ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
293
+ </button>
294
+ ) : null}
295
+ </div>
296
+ ) : null}
297
+ {shown.length > 0 ? (
298
+ <dl className="m-0 min-w-0 space-y-2">
299
+ {shown.map((field) => (
300
+ <div
301
+ key={field.path}
302
+ className="grid min-w-0 gap-x-4 gap-y-0.5 sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)]"
303
+ >
304
+ <dt className="m-0 break-words text-og-xs text-og-fg-muted sm:pt-px sm:text-og-sm">
305
+ {field.label}
306
+ </dt>
307
+ <dd className="m-0 min-w-0 whitespace-pre-wrap break-words text-og-fg [overflow-wrap:anywhere]">
308
+ {field.protected ? (
309
+ <span className="text-og-fg-muted">Hidden</span>
310
+ ) : field.truncated && canOpen ? (
311
+ <button
312
+ type="button"
313
+ data-review-path={field.path}
314
+ aria-label={`View ${field.label}: ${field.preview}`}
315
+ onClick={() => onViewDetails!(field.path)}
316
+ className={cn(linkButton, "max-w-full")}
317
+ >
318
+ <span className="line-clamp-4 min-w-0 break-words [overflow-wrap:anywhere]">
319
+ {field.preview}
320
+ </span>
321
+ <ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
322
+ </button>
323
+ ) : (
324
+ field.preview
325
+ )}
326
+ </dd>
327
+ </div>
328
+ ))}
329
+ </dl>
330
+ ) : null}
331
+ {hiddenFields > 0 ? (
332
+ <p className="m-0 text-og-xs text-og-fg-muted">
333
+ {hiddenFields === 1 ? "1 more value" : `${hiddenFields} more values`} in details
334
+ </p>
335
+ ) : null}
336
+ </div>
337
+ );
338
+ }
339
+
340
+ export function DeclineButton({
341
+ busy,
342
+ submitting,
343
+ label,
344
+ onClick,
345
+ }: {
346
+ busy: boolean;
347
+ submitting: boolean;
348
+ label?: string | undefined;
349
+ onClick: () => void;
350
+ }) {
351
+ return (
352
+ <button
353
+ type="button"
354
+ disabled={busy}
355
+ onClick={onClick}
356
+ className="inline-flex min-h-9 items-center justify-center rounded-og-md border border-og-border bg-og-surface-1 px-3 py-1.5 text-og-sm font-medium text-og-fg transition-colors hover:border-og-border-strong hover:bg-og-hover disabled:opacity-60 [@media(pointer:coarse)]:min-h-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
357
+ >
358
+ {submitting ? "Declining…" : (label ?? "Decline")}
359
+ </button>
360
+ );
361
+ }
362
+
363
+ /** One host-neutral review. All callbacks refer to the immutable action; none can edit it. */
364
+ export function ToolActionReviewCard({
365
+ review,
366
+ onApprove,
367
+ onReject,
368
+ submitting,
369
+ disabled,
370
+ onViewDetails,
371
+ pendingLabel,
372
+ rejectLabel,
373
+ bare,
374
+ }: {
375
+ review: ToolActionReview;
376
+ onApprove?: (() => void) | undefined;
377
+ onReject?: (() => void) | undefined;
378
+ submitting?: "approve" | "reject" | null | undefined;
379
+ disabled?: boolean | undefined;
380
+ onViewDetails?: ((path: string) => void) | undefined;
381
+ pendingLabel?: string | undefined;
382
+ rejectLabel?: string | undefined;
383
+ /** Render without card chrome, e.g. inside an expanded timeline row. */
384
+ bare?: boolean | undefined;
385
+ }) {
386
+ const titleId = useId();
387
+ const canApprove = review.availableActions.includes("approve") && !!onApprove;
388
+ const canReject = review.availableActions.includes("reject") && !!onReject;
389
+ const actionable = review.status === "pending" && (canApprove || canReject);
390
+ const busy = Boolean(disabled || submitting);
391
+ const tone: Tone = statusTone[review.status];
392
+ const eyebrow = submitting
393
+ ? submitting === "approve"
394
+ ? "Approving…"
395
+ : "Declining…"
396
+ : review.status === "pending"
397
+ ? (pendingLabel ?? statusText.pending)
398
+ : statusText[review.status];
399
+ const detailsLink =
400
+ review.detailsAvailable && onViewDetails ? (
401
+ <button
402
+ type="button"
403
+ className="inline-flex min-h-8 items-center rounded-og-sm text-og-sm text-og-fg-muted underline decoration-og-border-strong underline-offset-4 hover:text-og-fg hover:decoration-current [@media(pointer:coarse)]:min-h-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
404
+ data-review-path=""
405
+ onClick={() => onViewDetails("")}
406
+ >
407
+ Details
408
+ </button>
409
+ ) : null;
410
+ const reason = review.reason.trim();
411
+ return (
412
+ <ToolReviewFrame
413
+ tone={tone}
414
+ icon={<StatusIcon status={review.status} busy={Boolean(submitting)} />}
415
+ eyebrow={eyebrow}
416
+ title={review.title}
417
+ titleId={titleId}
418
+ subtitle={review.accountLabel}
419
+ bare={bare}
420
+ aria-busy={Boolean(submitting)}
421
+ data-og-tool-review=""
422
+ data-review-status={review.status}
423
+ footer={
424
+ actionable ? (
425
+ <>
426
+ <div className="flex min-w-0 flex-1 basis-48 flex-wrap items-center gap-x-3 gap-y-1">
427
+ {detailsLink}
428
+ {reason ? (
429
+ <span className="min-w-0 text-og-xs leading-relaxed text-og-fg-subtle">
430
+ {reason}
431
+ </span>
432
+ ) : null}
433
+ </div>
434
+ <div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
435
+ {canReject ? (
436
+ <DeclineButton
437
+ busy={busy}
438
+ submitting={submitting === "reject"}
439
+ label={rejectLabel}
440
+ onClick={onReject!}
441
+ />
442
+ ) : null}
443
+ {canApprove ? (
444
+ <button
445
+ type="button"
446
+ disabled={busy}
447
+ onClick={onApprove}
448
+ className="inline-flex min-h-9 items-center justify-center gap-2 rounded-og-md border border-og-primary-border bg-og-primary px-3.5 py-1.5 text-og-sm font-medium text-og-primary-fg transition-colors hover:bg-og-primary-hover disabled:opacity-60 [@media(pointer:coarse)]:min-h-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
449
+ >
450
+ {submitting === "approve" ? (
451
+ <LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden="true" />
452
+ ) : null}
453
+ {submitting === "approve" ? "Approving…" : review.approveLabel}
454
+ </button>
455
+ ) : null}
456
+ </div>
457
+ </>
458
+ ) : undefined
459
+ }
460
+ >
461
+ {actionable && !canApprove ? (
462
+ <p className="m-0 mb-3 text-og-sm leading-relaxed text-og-fg">{UNRECOVERABLE_TEXT}</p>
463
+ ) : null}
464
+ <ReviewFacts
465
+ review={review}
466
+ onViewDetails={onViewDetails}
467
+ showStatusDetail={review.status !== "pending"}
468
+ />
469
+ {!actionable && detailsLink ? <div className="mt-2">{detailsLink}</div> : null}
470
+ </ToolReviewFrame>
471
+ );
472
+ }
473
+
474
+ /** Full-page detail content: hosts choose navigation, never a nested scrolling panel. */
475
+ export function ToolActionReviewDetails({
476
+ review,
477
+ path: initialPath = "",
478
+ load,
479
+ onBack,
480
+ manageFocus = true,
481
+ }: {
482
+ review: ToolActionReview;
483
+ path?: string;
484
+ load: ToolReviewDetailsLoader;
485
+ onBack: () => void;
486
+ /** Hosts rendering simultaneous previews can retain their own focus. */
487
+ manageFocus?: boolean;
488
+ }) {
489
+ const [path, setPath] = useState(initialPath);
490
+ const [trail, setTrail] = useState<Array<{ path: string; label: string }>>([]);
491
+ const [offset, setOffset] = useState(0);
492
+ const [page, setPage] = useState<ToolReviewDetailsPage | null>(null);
493
+ const [error, setError] = useState(false);
494
+ const [retry, setRetry] = useState(0);
495
+ const heading = useRef<HTMLHeadingElement>(null);
496
+ const focusedOnce = useRef(false);
497
+ useEffect(() => {
498
+ if (page && manageFocus && !focusedOnce.current) {
499
+ focusedOnce.current = true;
500
+ heading.current?.focus({ preventScroll: true });
501
+ heading.current?.scrollIntoView({ block: "start" });
502
+ }
503
+ }, [page, manageFocus]);
504
+ useEffect(() => {
505
+ let active = true;
506
+ setPage(null);
507
+ setError(false);
508
+ void load(review, path, offset).then(
509
+ (value) => {
510
+ if (!active) return;
511
+ if (
512
+ value.actionDigest !== review.actionDigest ||
513
+ value.id !== review.id ||
514
+ value.path !== path
515
+ ) {
516
+ setError(true);
517
+ return;
518
+ }
519
+ setPage(value);
520
+ },
521
+ () => {
522
+ if (active) setError(true);
523
+ },
524
+ );
525
+ return () => {
526
+ active = false;
527
+ };
528
+ }, [review, path, offset, load, retry]);
529
+ const open = (next: string, label: string) => {
530
+ setTrail((value) => [...value, { path, label }]);
531
+ setPath(next);
532
+ setOffset(0);
533
+ };
534
+ const up = () => {
535
+ const previous = trail.at(-1);
536
+ setTrail((value) => value.slice(0, -1));
537
+ setPath(previous?.path ?? initialPath);
538
+ setOffset(0);
539
+ };
540
+ return (
541
+ <section
542
+ className="og-root mx-auto w-full max-w-3xl px-4 py-5 text-og-fg sm:px-6"
543
+ data-og-review-details=""
544
+ >
545
+ <button
546
+ type="button"
547
+ onClick={onBack}
548
+ className="mb-4 inline-flex min-h-11 items-center gap-2 rounded-og-md pr-2 text-og-sm text-og-fg-muted hover:text-og-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
549
+ >
550
+ <ArrowLeftIcon className="size-4" aria-hidden="true" />
551
+ Back to review
552
+ </button>
553
+ <h2
554
+ ref={heading}
555
+ tabIndex={-1}
556
+ className="m-0 break-words text-og-lg font-semibold outline-hidden [overflow-wrap:anywhere]"
557
+ >
558
+ {review.title}
559
+ </h2>
560
+ {review.accountLabel ? (
561
+ <p className="m-0 mt-1 text-og-sm text-og-fg-muted">{review.accountLabel}</p>
562
+ ) : null}
563
+ <p className="m-0 mt-2 text-og-xs text-og-fg-subtle">
564
+ The exact values saved for this action. Passwords and keys stay hidden.
565
+ </p>
566
+ {trail.length > 0 ? (
567
+ <button
568
+ type="button"
569
+ className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-og-sm text-og-fg-muted underline underline-offset-4 hover:text-og-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
570
+ onClick={up}
571
+ >
572
+ <ArrowLeftIcon className="size-3.5" aria-hidden="true" />
573
+ Back to {trail.at(-1)?.label ?? "all values"}
574
+ </button>
575
+ ) : null}
576
+ {error ? (
577
+ <div role="alert" className="mt-4 py-4 text-og-sm">
578
+ Couldn't load these details.{" "}
579
+ <button
580
+ type="button"
581
+ onClick={() => setRetry((value) => value + 1)}
582
+ className="min-h-11 underline underline-offset-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
583
+ >
584
+ Try again
585
+ </button>
586
+ </div>
587
+ ) : !page ? (
588
+ <p role="status" className="mt-4 py-4 text-og-sm text-og-fg-muted">
589
+ Loading details…
590
+ </p>
591
+ ) : (
592
+ <>
593
+ <dl className="m-0 mt-4 divide-y divide-og-border border-y border-og-border">
594
+ {page.items.map((item) => (
595
+ <div key={`${page.path}:${item.label}`} className="py-3">
596
+ <dt className="m-0 text-og-xs text-og-fg-muted">{item.label}</dt>
597
+ <dd className="m-0 mt-1 whitespace-pre-wrap break-words text-og-sm leading-relaxed [overflow-wrap:anywhere]">
598
+ {item.truncated && item.path ? (
599
+ <button
600
+ type="button"
601
+ aria-label={`Open ${item.label}`}
602
+ className={cn(linkButton, "max-w-full")}
603
+ onClick={() => open(item.path!, item.label)}
604
+ >
605
+ <span className="min-w-0 break-words focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40">
606
+ {item.value}
607
+ </span>
608
+ <ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
609
+ </button>
610
+ ) : (
611
+ item.value
612
+ )}
613
+ </dd>
614
+ </div>
615
+ ))}
616
+ </dl>
617
+ {page.total > page.items.length || offset > 0 ? (
618
+ <nav
619
+ aria-label="Detail pages"
620
+ className="mt-4 flex flex-wrap items-center justify-between gap-3 text-og-sm"
621
+ >
622
+ <button
623
+ type="button"
624
+ disabled={offset === 0}
625
+ onClick={() => setOffset(Math.max(0, offset - 25))}
626
+ className="min-h-11 rounded-og-md border border-og-border px-3 hover:bg-og-hover disabled:opacity-40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
627
+ >
628
+ Previous
629
+ </button>
630
+ <span className="text-og-fg-muted tabular-nums">
631
+ {page.total === 0
632
+ ? "No items"
633
+ : `${(offset + 1).toLocaleString()}–${(offset + page.items.length).toLocaleString()} of ${page.total.toLocaleString()}`}
634
+ </span>
635
+ <button
636
+ type="button"
637
+ disabled={page.nextOffset === null}
638
+ onClick={() => setOffset(page.nextOffset ?? offset)}
639
+ className="min-h-11 rounded-og-md border border-og-border px-3 hover:bg-og-hover disabled:opacity-40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
640
+ >
641
+ Next
642
+ </button>
643
+ </nav>
644
+ ) : null}
645
+ </>
646
+ )}
647
+ </section>
648
+ );
649
+ }