@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
@@ -1,39 +1,56 @@
1
- import { CheckIcon, ShieldCheckIcon, XIcon } from "lucide-react";
2
- import { Fragment, useEffect, useId, useRef, useState, type ReactNode } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { dequal } from "dequal";
3
+ import { ChevronLeftIcon, ChevronRightIcon, ShieldCheckIcon } from "lucide-react";
4
+ import {
5
+ OpenGeniApiError,
6
+ toolReviewAction,
7
+ toolReviewFields,
8
+ toolReviewDetails,
9
+ type ToolActionReview,
10
+ } from "@opengeni/sdk";
3
11
  import type { PendingApproval } from "../approvals";
4
12
  import { cn } from "../lib/cn";
5
13
  import { useErrorMessage } from "../lib/error-message";
6
14
  import { toolDisplayName } from "../timeline/tool-display-name";
15
+ import {
16
+ ToolActionReviewCard,
17
+ ToolActionReviewDetails,
18
+ ToolReviewFrame,
19
+ DeclineButton,
20
+ UNRECOVERABLE_TEXT,
21
+ type ToolReviewDetailsLoader,
22
+ } from "./tool-action-review";
7
23
 
8
24
  export type ApprovalSurfaceMessages = {
9
25
  title: string;
10
- /** Screen-reader context for the decision; not shown visually. */
11
26
  description: string;
12
27
  approve: string;
13
28
  reject: string;
14
29
  approving: string;
15
30
  rejecting: string;
16
31
  formatToolName: (name: string) => string;
17
- /** Toggle for the exact arguments when they are shown as readable fields. */
18
32
  showDetails?: string | undefined;
19
33
  hideDetails?: string | undefined;
20
34
  };
21
-
22
35
  export const defaultApprovalSurfaceMessages: ApprovalSurfaceMessages = {
23
36
  title: "Approval needed",
24
- description: "Review the requested action before the agent continues.",
25
- approve: "Approve",
26
- reject: "Reject",
37
+ description: "Review the action before it runs.",
38
+ approve: "Approve action",
39
+ reject: "Decline",
27
40
  approving: "Approving…",
28
- rejecting: "Rejecting…",
41
+ rejecting: "Declining…",
29
42
  formatToolName: (name) =>
30
43
  name.includes("__") || /^[a-f0-9]{64}$/.test(name)
31
44
  ? toolDisplayName(name)
32
- : name.replaceAll("_", " ").replaceAll(".", " › "),
33
- showDetails: "Show exact arguments",
34
- hideDetails: "Hide exact arguments",
45
+ : humanToolName(name.replaceAll("_", " ").replaceAll(".", " › ")),
46
+ showDetails: "View full details",
47
+ hideDetails: "Back to review",
35
48
  };
36
-
49
+ /** "batch modify messages" → "Modify messages": how a call is grouped is not what it does. */
50
+ function humanToolName(name: string): string {
51
+ const phrase = name.replace(/^batch\s+/i, "").trim();
52
+ return phrase ? phrase.charAt(0).toUpperCase() + phrase.slice(1) : name;
53
+ }
37
54
  export type ApprovalSurfaceProps = {
38
55
  approvals: PendingApproval[];
39
56
  onApprove: (approval: PendingApproval) => void | Promise<void>;
@@ -42,291 +59,403 @@ export type ApprovalSurfaceProps = {
42
59
  error?: string | Error | null | undefined;
43
60
  messages?: Partial<ApprovalSurfaceMessages> | undefined;
44
61
  renderApproval?: ((approval: PendingApproval) => ReactNode) | undefined;
62
+ /** Authenticated server facts; hosts need not import any web-app code. */
63
+ loadReview?: ((approval: PendingApproval) => Promise<ToolActionReview>) | undefined;
64
+ loadDetails?: ToolReviewDetailsLoader | undefined;
65
+ onViewDetails?: ((review: ToolActionReview, path: string) => void) | undefined;
66
+ selectedApprovalId?: string | null | undefined;
67
+ onSelectedApprovalChange?: ((id: string) => void) | undefined;
45
68
  className?: string | undefined;
46
69
  };
47
-
48
- const APPROVAL_ARGUMENT_PREVIEW_CHARACTERS = 4_000;
49
- const APPROVAL_FIELD_LIMIT = 12;
50
- const APPROVAL_LIST_LIMIT = 10;
51
-
52
- function approvalArgumentsPreview(value: unknown): string | null {
53
- if (value === undefined) return null;
54
- let serialized: string;
55
- try {
56
- serialized =
57
- typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
58
- } catch {
59
- serialized = "[Arguments unavailable]";
60
- }
61
- const characters = Array.from(serialized);
62
- if (characters.length <= APPROVAL_ARGUMENT_PREVIEW_CHARACTERS) return serialized;
63
- return `${characters.slice(0, APPROVAL_ARGUMENT_PREVIEW_CHARACTERS).join("")}\n… ${characters.length - APPROVAL_ARGUMENT_PREVIEW_CHARACTERS} characters omitted`;
64
- }
65
-
66
70
  export type ApprovalField = { key: string; label: string; value: string };
67
-
68
- const ACRONYMS = new Set(["id", "ids", "url", "uri", "api", "ip", "sku", "utc", "iban", "vat"]);
69
-
70
- /** "paymentId" / "payment_id" → "Payment ID". */
71
- function fieldLabel(key: string): string {
72
- const words = key
73
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
74
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
75
- .split(/[\s_.-]+/)
76
- .filter(Boolean)
77
- .map((word) => (ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.toLowerCase()));
78
- const label = words.join(" ");
79
- return label ? label.charAt(0).toUpperCase() + label.slice(1) : key;
80
- }
81
-
82
- function scalarText(value: unknown): string | null {
83
- if (typeof value === "string") return value;
84
- if (typeof value === "number" || typeof value === "bigint") return String(value);
85
- if (typeof value === "boolean") return value ? "Yes" : "No";
86
- if (value === null) return "None";
87
- return null;
88
- }
89
-
90
- /**
91
- * Readable fields for a flat argument object (scalars and short scalar lists).
92
- * Returns null when the arguments are nested, large, or not an object, so the
93
- * caller shows the exact JSON instead of a lossy summary.
94
- */
71
+ /** @deprecated Prefer the versioned server review; retained for small custom renderers. */
95
72
  export function approvalArgumentFields(value: unknown): ApprovalField[] | null {
96
- let record = value;
97
- if (typeof record === "string") {
98
- try {
99
- record = JSON.parse(record) as unknown;
100
- } catch {
101
- return null;
102
- }
103
- }
104
- if (!record || typeof record !== "object" || Array.isArray(record)) return null;
105
- const entries = Object.entries(record as Record<string, unknown>);
106
- if (entries.length === 0 || entries.length > APPROVAL_FIELD_LIMIT) return null;
107
- const fields: ApprovalField[] = [];
108
- for (const [key, entry] of entries) {
109
- let text = scalarText(entry);
110
- if (text === null && Array.isArray(entry) && entry.length <= APPROVAL_LIST_LIMIT) {
111
- const items = entry.map(scalarText);
112
- if (items.every((item): item is string => item !== null)) text = items.join(", ");
113
- }
114
- if (text === null || text.length > APPROVAL_ARGUMENT_PREVIEW_CHARACTERS) return null;
115
- fields.push({ key, label: fieldLabel(key), value: text });
116
- }
117
- return fields;
118
- }
119
-
120
- function isEmptyArguments(value: unknown): boolean {
121
- return (
122
- value === undefined ||
123
- value === null ||
124
- (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0)
125
- );
73
+ const projected = toolReviewFields(value);
74
+ return projected.fields.length
75
+ ? projected.fields.map((field) => ({
76
+ key: field.path.slice(1),
77
+ label: field.label,
78
+ value: field.preview,
79
+ }))
80
+ : null;
126
81
  }
127
82
 
128
- function approvalOwnershipKey(approval: PendingApproval): string {
129
- return `${approval.id}\u0000${approval.name}\u0000${approvalArgumentsPreview(approval.arguments) ?? ""}`;
130
- }
131
-
132
- /**
133
- * Host-neutral approval presentation backed by the native pending-approval
134
- * projection and control callbacks. It owns only presentation and duplicate
135
- * click fencing; OpenGeni remains the authority for approval state.
136
- */
83
+ /** Hosts provide decisions; this surface never mutates the approved payload. */
137
84
  export function ApprovalSurface({
138
85
  approvals,
139
86
  onApprove,
140
87
  onReject,
141
- responding = false,
88
+ responding,
142
89
  error,
143
90
  messages: overrides,
144
91
  renderApproval,
92
+ loadReview,
93
+ loadDetails,
94
+ onViewDetails,
95
+ selectedApprovalId,
96
+ onSelectedApprovalChange,
145
97
  className,
146
98
  }: ApprovalSurfaceProps) {
147
- const titleId = useId();
148
- const formatError = useErrorMessage();
149
- const descriptionId = useId();
150
99
  const messages = { ...defaultApprovalSurfaceMessages, ...overrides };
100
+ const formatError = useErrorMessage();
151
101
  const [pending, setPending] = useState<{
152
- approvalKey: string;
102
+ id: string;
153
103
  decision: "approve" | "reject";
154
- token: symbol;
155
104
  } | null>(null);
156
- const pendingRef = useRef<{ approvalKey: string; token: symbol } | null>(null);
105
+ const pendingRef = useRef<string | null>(null);
157
106
  const [decisionError, setDecisionError] = useState<{ cause: unknown } | null>(null);
158
-
107
+ const [localSelectedId, setLocalSelectedId] = useState<string | null>(null);
108
+ const selectedId = selectedApprovalId === undefined ? localSelectedId : selectedApprovalId;
109
+ const setSelectedId = (id: string) => {
110
+ setLocalSelectedId(id);
111
+ onSelectedApprovalChange?.(id);
112
+ };
113
+ const surface = useRef<HTMLElement>(null);
159
114
  useEffect(() => {
160
- if (
161
- pending &&
162
- !approvals.some((approval) => approvalOwnershipKey(approval) === pending.approvalKey)
163
- ) {
164
- if (pendingRef.current?.token === pending.token) {
165
- pendingRef.current = null;
166
- }
167
- setPending((current) => (current?.token === pending.token ? null : current));
115
+ if (pending && !approvals.some((approval) => approval.id === pending.id)) {
116
+ pendingRef.current = null;
117
+ setPending(null);
118
+ requestAnimationFrame(() => surface.current?.querySelector<HTMLElement>("h3")?.focus());
168
119
  }
169
120
  }, [approvals, pending]);
170
-
171
- if (approvals.length === 0) return null;
172
- const busy = responding || pendingRef.current !== null;
173
- const decide = async (
174
- approval: PendingApproval,
175
- decision: "approve" | "reject",
176
- ): Promise<void> => {
177
- if (responding || pendingRef.current !== null) return;
178
- const approvalKey = approvalOwnershipKey(approval);
179
- const token = Symbol(approvalKey);
180
- pendingRef.current = { approvalKey, token };
181
- setPending({ approvalKey, decision, token });
121
+ if (!approvals.length) return null;
122
+ const selectedIndex = Math.max(
123
+ 0,
124
+ approvals.findIndex((approval) => approval.id === selectedId),
125
+ );
126
+ const selected = approvals[selectedIndex]!;
127
+ const decide = async (approval: PendingApproval, decision: "approve" | "reject") => {
128
+ if (responding || pendingRef.current) return;
129
+ pendingRef.current = approval.id;
130
+ setPending({ id: approval.id, decision });
182
131
  setDecisionError(null);
183
132
  try {
184
133
  await (decision === "approve" ? onApprove(approval) : onReject(approval));
185
- // Keep the decision fenced until the authoritative projection removes or
186
- // replaces this exact approval. A successful callback is transport
187
- // acceptance, not settlement.
188
134
  } catch (cause) {
189
- if (pendingRef.current?.token === token) {
135
+ if (pendingRef.current === approval.id) {
190
136
  pendingRef.current = null;
191
- setPending((current) => (current?.token === token ? null : current));
137
+ setPending(null);
192
138
  setDecisionError({ cause });
193
139
  }
194
140
  }
195
141
  };
196
- const errorMessage = decisionError
142
+ const errorText = decisionError
197
143
  ? formatError(decisionError.cause)
198
144
  : error instanceof Error
199
145
  ? formatError(error)
200
146
  : error;
201
-
202
147
  return (
203
148
  <section
204
- className={cn(
205
- "og-root box-border flex w-full flex-col rounded-og-lg border border-og-status-waiting/35 bg-og-status-waiting/5 shadow-og-sm",
206
- className,
207
- )}
208
- aria-labelledby={titleId}
209
- aria-describedby={descriptionId}
149
+ ref={surface}
150
+ aria-label={messages.title}
151
+ className={cn("og-root box-border w-full min-w-0 space-y-2 py-2", className)}
210
152
  data-og-approval-surface=""
211
153
  >
212
- <h2 id={titleId} className="sr-only">
213
- {messages.title}
214
- </h2>
215
- <p id={descriptionId} className="sr-only">
216
- {messages.description}
217
- </p>
218
- <div className="flex flex-col divide-y divide-og-status-waiting/20">
219
- {approvals.map((approval) => {
220
- const approvalKey = approvalOwnershipKey(approval);
221
- const active = pending?.approvalKey === approvalKey ? pending.decision : null;
222
- return (
223
- <article
224
- key={approval.id}
225
- data-approval-id={approval.id}
226
- className="flex flex-col gap-3 p-4"
154
+ {approvals.length > 1 && (
155
+ <nav
156
+ aria-label="Actions to review"
157
+ className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-og-xs text-og-fg-muted"
158
+ >
159
+ <span className="font-medium text-og-status-waiting">
160
+ {approvals.length} actions are waiting for you
161
+ </span>
162
+ <div className="flex items-center gap-1">
163
+ <button
164
+ type="button"
165
+ aria-label="Previous action"
166
+ disabled={selectedIndex === 0}
167
+ onClick={() => setSelectedId(approvals[selectedIndex - 1]!.id)}
168
+ className="inline-flex min-h-8 min-w-8 items-center justify-center rounded-og-md hover:bg-og-hover hover:text-og-fg disabled:opacity-40 disabled:hover:bg-transparent [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
169
+ >
170
+ <ChevronLeftIcon className="size-4" aria-hidden="true" />
171
+ </button>
172
+ <span className="tabular-nums" aria-live="polite">
173
+ {selectedIndex + 1} of {approvals.length}
174
+ </span>
175
+ <button
176
+ type="button"
177
+ aria-label="Next action"
178
+ disabled={selectedIndex === approvals.length - 1}
179
+ onClick={() => setSelectedId(approvals[selectedIndex + 1]!.id)}
180
+ className="inline-flex min-h-8 min-w-8 items-center justify-center rounded-og-md hover:bg-og-hover hover:text-og-fg disabled:opacity-40 disabled:hover:bg-transparent [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
227
181
  >
228
- {renderApproval ? (
229
- <div className="min-w-0">
230
- <p className="mb-1 text-og-xs font-medium text-og-status-waiting" aria-hidden>
231
- {messages.title}
232
- </p>
233
- {renderApproval(approval)}
234
- </div>
235
- ) : (
236
- <ApprovalDetails approval={approval} messages={messages} />
237
- )}
238
- <div className="flex flex-wrap items-center justify-end gap-2">
239
- <button
240
- type="button"
241
- disabled={busy}
242
- onClick={() => void decide(approval, "reject")}
243
- className="inline-flex min-h-8 items-center gap-1.5 rounded-og-md border border-og-border bg-og-surface-1 px-3 py-1 text-og-sm font-medium text-og-fg transition-colors hover:border-og-border-strong disabled:opacity-50"
244
- >
245
- <XIcon aria-hidden="true" className="size-3.5" />
246
- {active === "reject" ? messages.rejecting : messages.reject}
247
- </button>
248
- <button
249
- type="button"
250
- disabled={busy}
251
- onClick={() => void decide(approval, "approve")}
252
- className="inline-flex min-h-8 items-center gap-1.5 rounded-og-md border border-og-primary-border bg-og-primary px-3 py-1 text-og-sm font-medium text-og-primary-fg transition-colors hover:bg-og-primary-hover disabled:opacity-50"
253
- >
254
- <CheckIcon aria-hidden="true" className="size-3.5" />
255
- {active === "approve" ? messages.approving : messages.approve}
256
- </button>
257
- </div>
258
- </article>
259
- );
260
- })}
182
+ <ChevronRightIcon className="size-4" aria-hidden="true" />
183
+ </button>
184
+ </div>
185
+ </nav>
186
+ )}
187
+ <div>
188
+ {[selected].map((approval) => (
189
+ <ApprovalRequest
190
+ key={approval.id}
191
+ approval={approval}
192
+ loadReview={loadReview}
193
+ loadDetails={loadDetails}
194
+ messages={messages}
195
+ custom={renderApproval?.(approval)}
196
+ onViewDetails={onViewDetails}
197
+ submitting={pending?.id === approval.id ? pending.decision : null}
198
+ disabled={Boolean(responding || pending)}
199
+ onApprove={() => void decide(approval, "approve")}
200
+ onReject={() => void decide(approval, "reject")}
201
+ />
202
+ ))}
261
203
  </div>
262
-
263
- {errorMessage ? (
264
- <p role="alert" className="px-4 pb-3 text-og-sm text-og-status-failed">
265
- {errorMessage}
204
+ {errorText && (
205
+ <p role="alert" className="m-0 px-1 text-og-sm text-og-status-failed">
206
+ {errorText}
266
207
  </p>
267
- ) : null}
208
+ )}
268
209
  </section>
269
210
  );
270
211
  }
271
212
 
272
- function ApprovalDetails({
213
+ function ApprovalRequest({
273
214
  approval,
215
+ loadReview,
216
+ loadDetails,
274
217
  messages,
218
+ custom,
219
+ onViewDetails,
220
+ ...actions
275
221
  }: {
276
222
  approval: PendingApproval;
223
+ loadReview: ApprovalSurfaceProps["loadReview"];
277
224
  messages: ApprovalSurfaceMessages;
225
+ loadDetails: ToolReviewDetailsLoader | undefined;
226
+ custom: ReactNode;
227
+ onViewDetails: ApprovalSurfaceProps["onViewDetails"];
228
+ submitting: "approve" | "reject" | null;
229
+ disabled: boolean;
230
+ onApprove: () => void;
231
+ onReject: () => void;
278
232
  }) {
279
- const [showExact, setShowExact] = useState(false);
280
- const fields = approvalArgumentFields(approval.arguments);
281
- const exact = isEmptyArguments(approval.arguments)
282
- ? null
283
- : approvalArgumentsPreview(approval.arguments);
284
- return (
285
- <div className="flex min-w-0 items-start gap-3">
286
- <span className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-og-md bg-og-status-waiting/12 text-og-status-waiting">
287
- <ShieldCheckIcon aria-hidden="true" className="size-4" />
288
- </span>
289
- <div className="min-w-0 flex-1">
290
- <p className="text-og-xs font-medium text-og-status-waiting">{messages.title}</p>
291
- <p className="mt-0.5 break-words text-og-md font-semibold text-og-fg">
292
- {approval.display?.title ??
293
- messages.formatToolName(approval.display?.toolName ?? approval.name)}
233
+ const [retry, setRetry] = useState(0);
234
+ // Event projection recreates the same approval on unrelated timeline updates.
235
+ // Keep one request for equal facts, but never reuse it across changed facts,
236
+ // an explicit retry, or a loader change (the host's client/access boundary).
237
+ const requestRef = useRef({ approval, loadReview, retry });
238
+ if (
239
+ requestRef.current.loadReview !== loadReview ||
240
+ requestRef.current.retry !== retry ||
241
+ !dequal(requestRef.current.approval, approval)
242
+ ) {
243
+ requestRef.current = { approval, loadReview, retry };
244
+ }
245
+ const request = requestRef.current;
246
+ const [result, setResult] = useState<{
247
+ request: typeof request;
248
+ review?: ToolActionReview;
249
+ failed?: boolean;
250
+ legacy?: boolean;
251
+ denied?: boolean;
252
+ } | null>(null);
253
+ // Hide stale facts during render, before passive effect cleanup can run.
254
+ const current = result?.request === request ? result : null;
255
+ const loaded = current?.review;
256
+ const failed = current?.failed;
257
+ const legacy = current?.legacy;
258
+ const denied = current?.denied;
259
+ const [detailPath, setDetailPath] = useState<string | null>(null);
260
+ useEffect(() => {
261
+ let active = true;
262
+ if (request.loadReview)
263
+ void request.loadReview(request.approval).then(
264
+ (review) => {
265
+ if (active) {
266
+ setResult(
267
+ review.id !== request.approval.id ? { request, failed: true } : { request, review },
268
+ );
269
+ }
270
+ },
271
+ (error) => {
272
+ if (active) {
273
+ // 404: no saved review (older or non-connector actions); use the
274
+ // approval's own arguments. 401/403: this person cannot review it.
275
+ if (error instanceof OpenGeniApiError && error.status === 404)
276
+ setResult({ request, legacy: true });
277
+ else if (
278
+ error instanceof OpenGeniApiError &&
279
+ (error.status === 401 || error.status === 403)
280
+ )
281
+ setResult({ request, failed: true, denied: true });
282
+ else setResult({ request, failed: true });
283
+ }
284
+ },
285
+ );
286
+ return () => {
287
+ active = false;
288
+ };
289
+ }, [request]);
290
+ const stableApproval = request.approval;
291
+ const { formatToolName, approve: approveLabel } = messages;
292
+ const fallback = useMemo<ToolActionReview>(
293
+ () => ({
294
+ version: 1,
295
+ id: stableApproval.id,
296
+ actionDigest: "",
297
+ revision: "legacy",
298
+ status: "pending",
299
+ ...toolReviewAction(stableApproval.name, stableApproval.arguments, {
300
+ kind: "generic",
301
+ title:
302
+ stableApproval.display?.title ??
303
+ formatToolName(stableApproval.display?.toolName ?? stableApproval.name),
304
+ }),
305
+ ...(stableApproval.display?.accountLabel
306
+ ? { accountLabel: stableApproval.display.accountLabel }
307
+ : {}),
308
+ ...toolReviewFields(stableApproval.arguments),
309
+ reason: "",
310
+ createdAt: "",
311
+ updatedAt: "",
312
+ approveLabel,
313
+ availableActions: stableApproval.arguments === undefined ? [] : ["approve", "reject"],
314
+ detailsAvailable: stableApproval.arguments !== undefined,
315
+ }),
316
+ [stableApproval, formatToolName, approveLabel],
317
+ );
318
+ const loadLegacyDetails = useCallback<ToolReviewDetailsLoader>(
319
+ async (value, path, offset) => ({
320
+ version: 1,
321
+ id: value.id,
322
+ actionDigest: value.actionDigest,
323
+ ...toolReviewDetails(stableApproval.arguments, undefined, path, offset),
324
+ }),
325
+ [stableApproval],
326
+ );
327
+ if (loadReview && !loaded && !failed && !legacy)
328
+ return (
329
+ <ToolReviewFrame
330
+ tone="waiting"
331
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
332
+ eyebrow={messages.title}
333
+ title={fallback.title}
334
+ subtitle={fallback.accountLabel}
335
+ aria-busy
336
+ data-og-tool-review-loading=""
337
+ >
338
+ <div role="status" className="space-y-2">
339
+ <span className="sr-only">Loading action details…</span>
340
+ <div className="h-3 w-3/4 animate-pulse rounded-og-sm bg-og-fg/8" aria-hidden="true" />
341
+ <div className="h-3 w-1/2 animate-pulse rounded-og-sm bg-og-fg/8" aria-hidden="true" />
342
+ </div>
343
+ </ToolReviewFrame>
344
+ );
345
+ if (denied)
346
+ return (
347
+ <ToolReviewFrame
348
+ tone="waiting"
349
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
350
+ eyebrow={messages.title}
351
+ title={fallback.title}
352
+ subtitle={fallback.accountLabel}
353
+ >
354
+ <p role="alert" className="m-0 text-og-sm text-og-fg">
355
+ You don't have access to review this action. Ask someone with access to this chat to
356
+ decide.
294
357
  </p>
295
- {approval.display?.accountLabel ? (
296
- <p className="mt-0.5 break-words text-og-xs text-og-fg-muted">
297
- {approval.display.accountLabel}
298
- </p>
299
- ) : null}
300
- {fields ? (
301
- <dl className="m-0 mt-3 grid grid-cols-[minmax(5.5rem,max-content)_minmax(0,1fr)] gap-x-5 gap-y-1.5 text-og-sm">
302
- {fields.map((field) => (
303
- <Fragment key={field.key}>
304
- <dt className="m-0 text-og-fg-muted">{field.label}</dt>
305
- <dd className="m-0 max-h-32 min-w-0 overflow-auto whitespace-pre-wrap break-words text-og-fg">
306
- {field.value}
307
- </dd>
308
- </Fragment>
309
- ))}
310
- </dl>
311
- ) : null}
312
- {exact !== null && (!fields || showExact) ? (
313
- <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-og-sm bg-og-fg/5 p-2.5 font-mono text-og-xs text-og-fg-muted">
314
- {exact}
315
- </pre>
316
- ) : null}
317
- {fields && exact !== null ? (
358
+ </ToolReviewFrame>
359
+ );
360
+ if (failed)
361
+ return (
362
+ <ToolReviewFrame
363
+ tone="waiting"
364
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
365
+ eyebrow={messages.title}
366
+ title={fallback.title}
367
+ subtitle={fallback.accountLabel}
368
+ footer={
369
+ <div className="ml-auto">
370
+ <DeclineButton
371
+ busy={Boolean(actions.disabled || actions.submitting)}
372
+ submitting={actions.submitting === "reject"}
373
+ label={messages.reject}
374
+ onClick={actions.onReject}
375
+ />
376
+ </div>
377
+ }
378
+ >
379
+ <p role="alert" className="m-0 text-og-sm text-og-fg">
380
+ Couldn't load this action, so it can't be approved yet. You can still decline it.{" "}
318
381
  <button
319
382
  type="button"
320
- aria-expanded={showExact}
321
- onClick={() => setShowExact((value) => !value)}
322
- className="mt-2 text-og-xs text-og-fg-muted underline-offset-2 hover:text-og-fg hover:underline"
383
+ className="min-h-8 font-medium underline underline-offset-4 [@media(pointer:coarse)]:min-h-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
384
+ onClick={() => setRetry((value) => value + 1)}
323
385
  >
324
- {showExact
325
- ? (messages.hideDetails ?? defaultApprovalSurfaceMessages.hideDetails)
326
- : (messages.showDetails ?? defaultApprovalSurfaceMessages.showDetails)}
386
+ Try again
327
387
  </button>
328
- ) : null}
388
+ </p>
389
+ </ToolReviewFrame>
390
+ );
391
+ if (!loaded && stableApproval.arguments === undefined)
392
+ return (
393
+ <div data-approval-id={approval.id} data-review-origin="pending">
394
+ {custom}
395
+ <ToolReviewFrame
396
+ tone="waiting"
397
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
398
+ eyebrow={messages.title}
399
+ title={fallback.title}
400
+ subtitle={fallback.accountLabel}
401
+ data-og-tool-review=""
402
+ footer={
403
+ <div className="ml-auto">
404
+ <DeclineButton
405
+ busy={Boolean(actions.disabled || actions.submitting)}
406
+ submitting={actions.submitting === "reject"}
407
+ label={messages.reject}
408
+ onClick={actions.onReject}
409
+ />
410
+ </div>
411
+ }
412
+ >
413
+ <p className="m-0 text-og-sm text-og-fg">{UNRECOVERABLE_TEXT}</p>
414
+ </ToolReviewFrame>
329
415
  </div>
416
+ );
417
+ const review = loaded ?? fallback;
418
+ if (detailPath !== null)
419
+ return (
420
+ <ToolActionReviewDetails
421
+ review={review}
422
+ path={detailPath}
423
+ load={loaded && loadDetails ? loadDetails : loadLegacyDetails}
424
+ onBack={() => {
425
+ const path = detailPath;
426
+ setDetailPath(null);
427
+ requestAnimationFrame(() => {
428
+ [
429
+ ...document.querySelectorAll<HTMLButtonElement>(
430
+ "[data-approval-id] button[data-review-path]",
431
+ ),
432
+ ]
433
+ .find(
434
+ (button) =>
435
+ button.closest("[data-approval-id]")?.getAttribute("data-approval-id") ===
436
+ approval.id && button.dataset.reviewPath === path,
437
+ )
438
+ ?.focus();
439
+ });
440
+ }}
441
+ />
442
+ );
443
+ return (
444
+ <div data-approval-id={approval.id} data-review-origin="pending">
445
+ {custom}
446
+ <ToolActionReviewCard
447
+ review={review}
448
+ {...actions}
449
+ pendingLabel={messages.title}
450
+ rejectLabel={messages.reject}
451
+ onViewDetails={
452
+ loaded && onViewDetails
453
+ ? (path) => onViewDetails(review, path)
454
+ : loadDetails || !loaded
455
+ ? setDetailPath
456
+ : undefined
457
+ }
458
+ />
330
459
  </div>
331
460
  );
332
461
  }