@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.
- package/README.md +52 -1
- package/dist/artifacts.js +2 -2
- package/dist/artifacts.js.map +1 -1
- package/dist/{chunk-ERGYJIBB.js → chunk-AWZLIMKX.js} +2 -2
- package/dist/{chunk-ERGYJIBB.js.map → chunk-AWZLIMKX.js.map} +1 -1
- package/dist/{chunk-FZCP527Y.js → chunk-CJUO7J5Z.js} +2 -2
- package/dist/{chunk-742CZ3L3.js → chunk-H5X55XH7.js} +16 -200
- package/dist/chunk-H5X55XH7.js.map +1 -0
- package/dist/{chunk-4OTO7KBK.js → chunk-LJ2UTJJE.js} +2737 -1580
- package/dist/chunk-LJ2UTJJE.js.map +1 -0
- package/dist/{chunk-CY7WK5FD.js → chunk-LN6TCXD3.js} +22 -1
- package/dist/chunk-LN6TCXD3.js.map +1 -0
- package/dist/{chunk-TMYGL6X5.js → chunk-MBMPHOMA.js} +71 -6
- package/dist/chunk-MBMPHOMA.js.map +1 -0
- package/dist/{chunk-RC6BZ44Q.js → chunk-OA6HFRQP.js} +2 -2
- package/dist/{chunk-X34BSY2R.js → chunk-WZMYFJD3.js} +242 -63
- package/dist/chunk-WZMYFJD3.js.map +1 -0
- package/dist/components/approval-surface.d.ts +11 -13
- package/dist/components/composer-transcription-control.d.ts +3 -0
- package/dist/components/open-geni-chat.d.ts +26 -7
- package/dist/components/session-conversation.d.ts +26 -3
- package/dist/components/session-list.d.ts +4 -1
- package/dist/components/session-proxy-scope.d.ts +16 -2
- package/dist/components/tool-action-review.d.ts +47 -0
- package/dist/components/tool-review-history.d.ts +25 -0
- package/dist/composer.js +1 -1
- package/dist/connect.js +1 -1
- package/dist/hooks/use-client-config-flags.d.ts +37 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +27 -11
- package/dist/index.js.map +1 -1
- package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
- package/dist/realtime/realtime-control.d.ts +2 -0
- package/dist/realtime.js +25 -4
- package/dist/realtime.js.map +1 -1
- package/dist/session-capability-policy.d.ts +20 -1
- package/dist/session-ui.d.ts +4 -1
- package/dist/session-ui.js +12 -6
- package/dist/session-ui.js.map +1 -1
- package/dist/session.js +6 -6
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/retained-loaders.d.ts +27 -0
- package/package.json +3 -3
- package/src/components/approval-surface.tsx +368 -239
- package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
- package/src/components/composer-transcription-control.tsx +14 -0
- package/src/components/composer.tsx +1 -0
- package/src/components/open-geni-chat.tsx +245 -72
- package/src/components/session-conversation.tsx +243 -58
- package/src/components/session-list.tsx +9 -2
- package/src/components/session-proxy-scope.tsx +47 -7
- package/src/components/tool-action-review.tsx +649 -0
- package/src/components/tool-review-history.tsx +88 -0
- package/src/hooks/use-client-config-flags.ts +102 -0
- package/src/hooks/use-session-events.ts +19 -16
- package/src/hooks/use-transcription.ts +6 -0
- package/src/hooks/use-voice-input.ts +6 -0
- package/src/index.ts +18 -1
- package/src/realtime/realtime-control.tsx +28 -1
- package/src/session-capability-policy.ts +119 -6
- package/src/session-ui.ts +9 -1
- package/src/timeline/index.ts +10 -0
- package/src/timeline/retained-loaders.ts +86 -0
- package/src/timeline/tool-display-name.ts +5 -1
- package/src/timeline/tool-renderers.tsx +148 -8
- package/styles/compiled.css +79 -15
- package/styles/tokens.css +1 -1
- package/dist/chunk-4OTO7KBK.js.map +0 -1
- package/dist/chunk-742CZ3L3.js.map +0 -1
- package/dist/chunk-CY7WK5FD.js.map +0 -1
- package/dist/chunk-TMYGL6X5.js.map +0 -1
- package/dist/chunk-X34BSY2R.js.map +0 -1
- /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
- /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
- /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
|
@@ -1,39 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
25
|
-
approve: "Approve",
|
|
26
|
-
reject: "
|
|
37
|
+
description: "Review the action before it runs.",
|
|
38
|
+
approve: "Approve action",
|
|
39
|
+
reject: "Decline",
|
|
27
40
|
approving: "Approving…",
|
|
28
|
-
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: "
|
|
34
|
-
hideDetails: "
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
102
|
+
id: string;
|
|
153
103
|
decision: "approve" | "reject";
|
|
154
|
-
token: symbol;
|
|
155
104
|
} | null>(null);
|
|
156
|
-
const pendingRef = useRef<
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (responding || pendingRef.current
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
135
|
+
if (pendingRef.current === approval.id) {
|
|
190
136
|
pendingRef.current = null;
|
|
191
|
-
setPending(
|
|
137
|
+
setPending(null);
|
|
192
138
|
setDecisionError({ cause });
|
|
193
139
|
}
|
|
194
140
|
}
|
|
195
141
|
};
|
|
196
|
-
const
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
className="flex
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
)
|
|
208
|
+
)}
|
|
268
209
|
</section>
|
|
269
210
|
);
|
|
270
211
|
}
|
|
271
212
|
|
|
272
|
-
function
|
|
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 [
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
onClick={() =>
|
|
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
|
-
|
|
325
|
-
? (messages.hideDetails ?? defaultApprovalSurfaceMessages.hideDetails)
|
|
326
|
-
: (messages.showDetails ?? defaultApprovalSurfaceMessages.showDetails)}
|
|
386
|
+
Try again
|
|
327
387
|
</button>
|
|
328
|
-
|
|
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
|
}
|