@opengeni/react 1.2.1-canary.37222965366001 → 1.3.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.
@@ -1,39 +1,46 @@
1
- import { CheckIcon, ShieldCheckIcon, XIcon } from "lucide-react";
2
- import { Fragment, useEffect, useId, useRef, useState, type ReactNode } from "react";
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ OpenGeniApiError,
4
+ toolReviewAction,
5
+ toolReviewFields,
6
+ toolReviewDetails,
7
+ type ToolActionReview,
8
+ } from "@opengeni/sdk";
3
9
  import type { PendingApproval } from "../approvals";
4
10
  import { cn } from "../lib/cn";
5
11
  import { useErrorMessage } from "../lib/error-message";
6
12
  import { toolDisplayName } from "../timeline/tool-display-name";
13
+ import {
14
+ ToolActionReviewCard,
15
+ ToolActionReviewDetails,
16
+ type ToolReviewDetailsLoader,
17
+ } from "./tool-action-review";
7
18
 
8
19
  export type ApprovalSurfaceMessages = {
9
20
  title: string;
10
- /** Screen-reader context for the decision; not shown visually. */
11
21
  description: string;
12
22
  approve: string;
13
23
  reject: string;
14
24
  approving: string;
15
25
  rejecting: string;
16
26
  formatToolName: (name: string) => string;
17
- /** Toggle for the exact arguments when they are shown as readable fields. */
18
27
  showDetails?: string | undefined;
19
28
  hideDetails?: string | undefined;
20
29
  };
21
-
22
30
  export const defaultApprovalSurfaceMessages: ApprovalSurfaceMessages = {
23
31
  title: "Approval needed",
24
- description: "Review the requested action before the agent continues.",
25
- approve: "Approve",
26
- reject: "Reject",
32
+ description: "Review the action before it runs.",
33
+ approve: "Approve action",
34
+ reject: "Decline",
27
35
  approving: "Approving…",
28
- rejecting: "Rejecting…",
36
+ rejecting: "Declining…",
29
37
  formatToolName: (name) =>
30
38
  name.includes("__") || /^[a-f0-9]{64}$/.test(name)
31
39
  ? toolDisplayName(name)
32
40
  : name.replaceAll("_", " ").replaceAll(".", " › "),
33
- showDetails: "Show exact arguments",
34
- hideDetails: "Hide exact arguments",
41
+ showDetails: "View full details",
42
+ hideDetails: "Back to review",
35
43
  };
36
-
37
44
  export type ApprovalSurfaceProps = {
38
45
  approvals: PendingApproval[];
39
46
  onApprove: (approval: PendingApproval) => void | Promise<void>;
@@ -42,291 +49,302 @@ export type ApprovalSurfaceProps = {
42
49
  error?: string | Error | null | undefined;
43
50
  messages?: Partial<ApprovalSurfaceMessages> | undefined;
44
51
  renderApproval?: ((approval: PendingApproval) => ReactNode) | undefined;
52
+ /** Authenticated server facts; hosts need not import any web-app code. */
53
+ loadReview?: ((approval: PendingApproval) => Promise<ToolActionReview>) | undefined;
54
+ loadDetails?: ToolReviewDetailsLoader | undefined;
55
+ onViewDetails?: ((review: ToolActionReview, path: string) => void) | undefined;
56
+ selectedApprovalId?: string | null | undefined;
57
+ onSelectedApprovalChange?: ((id: string) => void) | undefined;
45
58
  className?: string | undefined;
46
59
  };
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
60
  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
- */
61
+ /** @deprecated Prefer the versioned server review; retained for small custom renderers. */
95
62
  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
- );
126
- }
127
-
128
- function approvalOwnershipKey(approval: PendingApproval): string {
129
- return `${approval.id}\u0000${approval.name}\u0000${approvalArgumentsPreview(approval.arguments) ?? ""}`;
63
+ const projected = toolReviewFields(value);
64
+ return projected.fields.length
65
+ ? projected.fields.map((field) => ({
66
+ key: field.path.slice(1),
67
+ label: field.label,
68
+ value: field.preview,
69
+ }))
70
+ : null;
130
71
  }
131
72
 
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
- */
73
+ /** Hosts provide decisions; this surface never mutates the approved payload. */
137
74
  export function ApprovalSurface({
138
75
  approvals,
139
76
  onApprove,
140
77
  onReject,
141
- responding = false,
78
+ responding,
142
79
  error,
143
80
  messages: overrides,
144
81
  renderApproval,
82
+ loadReview,
83
+ loadDetails,
84
+ onViewDetails,
85
+ selectedApprovalId,
86
+ onSelectedApprovalChange,
145
87
  className,
146
88
  }: ApprovalSurfaceProps) {
147
- const titleId = useId();
148
- const formatError = useErrorMessage();
149
- const descriptionId = useId();
150
89
  const messages = { ...defaultApprovalSurfaceMessages, ...overrides };
151
- const [pending, setPending] = useState<{
152
- approvalKey: string;
153
- decision: "approve" | "reject";
154
- token: symbol;
155
- } | null>(null);
156
- const pendingRef = useRef<{ approvalKey: string; token: symbol } | null>(null);
90
+ const formatError = useErrorMessage();
91
+ const [pending, setPending] = useState<{ id: string; decision: "approve" | "reject" } | null>(
92
+ null,
93
+ );
94
+ const pendingRef = useRef<string | null>(null);
157
95
  const [decisionError, setDecisionError] = useState<{ cause: unknown } | null>(null);
158
-
96
+ const [localSelectedId, setLocalSelectedId] = useState<string | null>(null);
97
+ const selectedId = selectedApprovalId === undefined ? localSelectedId : selectedApprovalId;
98
+ const setSelectedId = (id: string) => {
99
+ setLocalSelectedId(id);
100
+ onSelectedApprovalChange?.(id);
101
+ };
102
+ const surface = useRef<HTMLElement>(null);
159
103
  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));
104
+ if (pending && !approvals.some((approval) => approval.id === pending.id)) {
105
+ pendingRef.current = null;
106
+ setPending(null);
107
+ requestAnimationFrame(() => surface.current?.querySelector<HTMLElement>("h3")?.focus());
168
108
  }
169
109
  }, [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 });
110
+ if (!approvals.length) return null;
111
+ const selectedIndex = Math.max(
112
+ 0,
113
+ approvals.findIndex((approval) => approval.id === selectedId),
114
+ );
115
+ const selected = approvals[selectedIndex]!;
116
+ const decide = async (approval: PendingApproval, decision: "approve" | "reject") => {
117
+ if (responding || pendingRef.current) return;
118
+ pendingRef.current = approval.id;
119
+ setPending({ id: approval.id, decision });
182
120
  setDecisionError(null);
183
121
  try {
184
122
  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
123
  } catch (cause) {
189
- if (pendingRef.current?.token === token) {
124
+ if (pendingRef.current === approval.id) {
190
125
  pendingRef.current = null;
191
- setPending((current) => (current?.token === token ? null : current));
126
+ setPending(null);
192
127
  setDecisionError({ cause });
193
128
  }
194
129
  }
195
130
  };
196
- const errorMessage = decisionError
131
+ const errorText = decisionError
197
132
  ? formatError(decisionError.cause)
198
133
  : error instanceof Error
199
134
  ? formatError(error)
200
135
  : error;
201
-
202
136
  return (
203
137
  <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}
138
+ ref={surface}
139
+ aria-label={messages.title}
140
+ className={cn("og-root box-border w-full min-w-0 border-t border-og-border", className)}
210
141
  data-og-approval-surface=""
211
142
  >
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"
143
+ {approvals.length > 1 && (
144
+ <nav
145
+ aria-label="Actions to review"
146
+ className="flex flex-wrap items-center justify-between gap-2 pt-3 text-og-xs text-og-fg-muted"
147
+ >
148
+ <span>{approvals.length} actions need review</span>
149
+ <div className="flex items-center gap-3">
150
+ <button
151
+ type="button"
152
+ disabled={selectedIndex === 0}
153
+ onClick={() => setSelectedId(approvals[selectedIndex - 1]!.id)}
154
+ className="min-h-11 disabled:opacity-40"
227
155
  >
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
- })}
156
+ Previous
157
+ </button>
158
+ <span>
159
+ {selectedIndex + 1} of {approvals.length}
160
+ </span>
161
+ <button
162
+ type="button"
163
+ disabled={selectedIndex === approvals.length - 1}
164
+ onClick={() => setSelectedId(approvals[selectedIndex + 1]!.id)}
165
+ className="min-h-11 disabled:opacity-40"
166
+ >
167
+ Next
168
+ </button>
169
+ </div>
170
+ </nav>
171
+ )}
172
+ <div className="divide-y divide-og-border">
173
+ {[selected].map((approval) => (
174
+ <ApprovalRequest
175
+ key={approval.id}
176
+ approval={approval}
177
+ loadReview={loadReview}
178
+ loadDetails={loadDetails}
179
+ messages={messages}
180
+ custom={renderApproval?.(approval)}
181
+ onViewDetails={onViewDetails}
182
+ submitting={pending?.id === approval.id ? pending.decision : null}
183
+ disabled={Boolean(responding || pending)}
184
+ onApprove={() => void decide(approval, "approve")}
185
+ onReject={() => void decide(approval, "reject")}
186
+ />
187
+ ))}
261
188
  </div>
262
-
263
- {errorMessage ? (
264
- <p role="alert" className="px-4 pb-3 text-og-sm text-og-status-failed">
265
- {errorMessage}
189
+ {errorText && (
190
+ <p role="alert" className="m-0 pb-3 text-og-sm text-og-status-failed">
191
+ {errorText}
266
192
  </p>
267
- ) : null}
193
+ )}
268
194
  </section>
269
195
  );
270
196
  }
271
197
 
272
- function ApprovalDetails({
198
+ function ApprovalRequest({
273
199
  approval,
200
+ loadReview,
201
+ loadDetails,
274
202
  messages,
203
+ custom,
204
+ onViewDetails,
205
+ ...actions
275
206
  }: {
276
207
  approval: PendingApproval;
208
+ loadReview: ApprovalSurfaceProps["loadReview"];
277
209
  messages: ApprovalSurfaceMessages;
210
+ loadDetails: ToolReviewDetailsLoader | undefined;
211
+ custom: ReactNode;
212
+ onViewDetails: ApprovalSurfaceProps["onViewDetails"];
213
+ submitting: "approve" | "reject" | null;
214
+ disabled: boolean;
215
+ onApprove: () => void;
216
+ onReject: () => void;
278
217
  }) {
279
- const [showExact, setShowExact] = useState(false);
280
- const fields = approvalArgumentFields(approval.arguments);
281
- const exact = isEmptyArguments(approval.arguments)
282
- ? null
283
- : approvalArgumentsPreview(approval.arguments);
218
+ const [loaded, setLoaded] = useState<ToolActionReview | null>(null);
219
+ const [failed, setFailed] = useState(false);
220
+ const [legacy, setLegacy] = useState(false);
221
+ const [retry, setRetry] = useState(0);
222
+ const [detailPath, setDetailPath] = useState<string | null>(null);
223
+ useEffect(() => {
224
+ let active = true;
225
+ setLoaded(null);
226
+ setFailed(false);
227
+ setLegacy(false);
228
+ if (loadReview)
229
+ void loadReview(approval).then(
230
+ (review) => {
231
+ if (active) {
232
+ if (review.id !== approval.id) setFailed(true);
233
+ else setLoaded(review);
234
+ }
235
+ },
236
+ (error) => {
237
+ if (active) {
238
+ if (
239
+ error instanceof OpenGeniApiError &&
240
+ error.status === 404 &&
241
+ approval.arguments !== undefined
242
+ )
243
+ setLegacy(true);
244
+ else setFailed(true);
245
+ }
246
+ },
247
+ );
248
+ return () => {
249
+ active = false;
250
+ };
251
+ }, [approval, retry, loadReview]);
252
+ if (loadReview && !loaded && !failed && !legacy)
253
+ return (
254
+ <div className="py-5 text-og-sm text-og-fg-muted" role="status">
255
+ Loading action details…
256
+ </div>
257
+ );
258
+ if (failed)
259
+ return (
260
+ <div className="py-4 text-og-sm text-og-fg" role="alert">
261
+ Action details could not be loaded.{" "}
262
+ <button
263
+ type="button"
264
+ className="min-h-11 underline underline-offset-4"
265
+ onClick={() => setRetry((value) => value + 1)}
266
+ >
267
+ Try again
268
+ </button>
269
+ </div>
270
+ );
271
+ const fallback: ToolActionReview = {
272
+ version: 1,
273
+ id: approval.id,
274
+ actionDigest: "",
275
+ revision: "legacy",
276
+ status: "pending",
277
+ ...toolReviewAction(approval.name, approval.arguments, {
278
+ kind: "generic",
279
+ title:
280
+ approval.display?.title ??
281
+ messages.formatToolName(approval.display?.toolName ?? approval.name),
282
+ }),
283
+ ...(approval.display?.accountLabel ? { accountLabel: approval.display.accountLabel } : {}),
284
+ ...toolReviewFields(approval.arguments),
285
+ reason: "This action requires your approval.",
286
+ createdAt: "",
287
+ updatedAt: "",
288
+ approveLabel: messages.approve,
289
+ availableActions: approval.arguments === undefined ? [] : ["approve", "reject"],
290
+ detailsAvailable: approval.arguments !== undefined,
291
+ };
292
+ const review = loaded ?? fallback;
293
+ if (detailPath !== null)
294
+ return (
295
+ <ToolActionReviewDetails
296
+ review={review}
297
+ path={detailPath}
298
+ load={
299
+ loaded && loadDetails
300
+ ? loadDetails
301
+ : async (value, path, offset) => ({
302
+ version: 1,
303
+ id: value.id,
304
+ actionDigest: value.actionDigest,
305
+ ...toolReviewDetails(approval.arguments, undefined, path, offset),
306
+ })
307
+ }
308
+ onBack={() => {
309
+ const path = detailPath;
310
+ setDetailPath(null);
311
+ requestAnimationFrame(() => {
312
+ [
313
+ ...document.querySelectorAll<HTMLButtonElement>(
314
+ "[data-approval-id] button[data-review-path]",
315
+ ),
316
+ ]
317
+ .find(
318
+ (button) =>
319
+ button.closest("[data-approval-id]")?.getAttribute("data-approval-id") ===
320
+ approval.id && button.dataset.reviewPath === path,
321
+ )
322
+ ?.focus();
323
+ });
324
+ }}
325
+ />
326
+ );
284
327
  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)}
328
+ <div data-approval-id={approval.id} data-review-origin="pending">
329
+ {custom}
330
+ <ToolActionReviewCard
331
+ review={review}
332
+ {...actions}
333
+ pendingLabel={messages.title}
334
+ rejectLabel={messages.reject}
335
+ onViewDetails={
336
+ loaded && onViewDetails
337
+ ? (path) => onViewDetails(review, path)
338
+ : loadDetails || !loaded
339
+ ? setDetailPath
340
+ : undefined
341
+ }
342
+ />
343
+ {!loaded && approval.arguments === undefined && (
344
+ <p className="pb-3 text-og-sm text-og-fg-muted">
345
+ Open the task with a compatible client to review this action.
294
346
  </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 ? (
318
- <button
319
- 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"
323
- >
324
- {showExact
325
- ? (messages.hideDetails ?? defaultApprovalSurfaceMessages.hideDetails)
326
- : (messages.showDetails ?? defaultApprovalSurfaceMessages.showDetails)}
327
- </button>
328
- ) : null}
329
- </div>
347
+ )}
330
348
  </div>
331
349
  );
332
350
  }