@opengeni/react 1.3.0 → 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 +21 -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-PGSX7UCP.js → chunk-LJ2UTJJE.js} +1369 -785
- 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-WWEUO2PL.js → chunk-WZMYFJD3.js} +190 -11
- package/dist/chunk-WZMYFJD3.js.map +1 -0
- package/dist/components/composer-transcription-control.d.ts +3 -0
- package/dist/components/open-geni-chat.d.ts +6 -2
- package/dist/components/session-conversation.d.ts +25 -19
- package/dist/components/session-list.d.ts +4 -1
- package/dist/components/tool-action-review.d.ts +29 -2
- package/dist/components/tool-review-history.d.ts +8 -4
- 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 +3 -3
- package/dist/index.js +21 -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.js +6 -6
- 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 +190 -79
- 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 +53 -13
- package/src/components/session-conversation.tsx +229 -63
- package/src/components/session-list.tsx +9 -2
- package/src/components/tool-action-review.tsx +502 -239
- package/src/components/tool-review-history.tsx +18 -43
- 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 +9 -0
- package/src/realtime/realtime-control.tsx +28 -1
- package/src/session-capability-policy.ts +119 -6
- 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 +143 -10
- package/styles/compiled.css +65 -21
- package/styles/tokens.css +1 -1
- package/dist/chunk-742CZ3L3.js.map +0 -1
- package/dist/chunk-CY7WK5FD.js.map +0 -1
- package/dist/chunk-PGSX7UCP.js.map +0 -1
- package/dist/chunk-TMYGL6X5.js.map +0 -1
- package/dist/chunk-WWEUO2PL.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,36 +1,364 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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";
|
|
3
11
|
import type { ToolActionReview, ToolReviewDetailsPage, ToolReviewStatus } from "@opengeni/sdk";
|
|
12
|
+
import { cn } from "../lib/cn";
|
|
4
13
|
|
|
5
14
|
export type ToolReviewDetailsLoader = (
|
|
6
15
|
review: ToolActionReview,
|
|
7
16
|
path: string,
|
|
8
17
|
offset: number,
|
|
9
18
|
) => Promise<ToolReviewDetailsPage>;
|
|
19
|
+
|
|
10
20
|
const statusText: Record<ToolReviewStatus, string> = {
|
|
11
|
-
pending: "
|
|
12
|
-
approved: "Approved
|
|
13
|
-
executing: "
|
|
14
|
-
completed: "
|
|
15
|
-
partial: "
|
|
16
|
-
unknown: "Outcome
|
|
21
|
+
pending: "Approval needed",
|
|
22
|
+
approved: "Approved",
|
|
23
|
+
executing: "Approved · running",
|
|
24
|
+
completed: "Approved · done",
|
|
25
|
+
partial: "Partly done",
|
|
26
|
+
unknown: "Outcome unknown",
|
|
17
27
|
rejected: "Declined",
|
|
18
28
|
cancelled: "Cancelled",
|
|
19
|
-
expired: "
|
|
29
|
+
expired: "Expired",
|
|
20
30
|
revoked: "Access unavailable",
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
blocked: "Blocked",
|
|
32
|
+
stale: "No longer valid",
|
|
33
|
+
failed: "Failed",
|
|
23
34
|
unavailable: "Details unavailable",
|
|
24
35
|
};
|
|
25
36
|
const statusDetail: Partial<Record<ToolReviewStatus, string>> = {
|
|
26
|
-
unknown: "
|
|
27
|
-
rejected: "This action
|
|
28
|
-
cancelled: "This action
|
|
29
|
-
expired: "
|
|
30
|
-
revoked: "
|
|
31
|
-
stale: "The tool or account changed
|
|
32
|
-
partial: "Some
|
|
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",
|
|
33
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
|
+
}
|
|
34
362
|
|
|
35
363
|
/** One host-neutral review. All callbacks refer to the immutable action; none can edit it. */
|
|
36
364
|
export function ToolActionReviewCard({
|
|
@@ -42,6 +370,7 @@ export function ToolActionReviewCard({
|
|
|
42
370
|
onViewDetails,
|
|
43
371
|
pendingLabel,
|
|
44
372
|
rejectLabel,
|
|
373
|
+
bare,
|
|
45
374
|
}: {
|
|
46
375
|
review: ToolActionReview;
|
|
47
376
|
onApprove?: (() => void) | undefined;
|
|
@@ -51,183 +380,94 @@ export function ToolActionReviewCard({
|
|
|
51
380
|
onViewDetails?: ((path: string) => void) | undefined;
|
|
52
381
|
pendingLabel?: string | undefined;
|
|
53
382
|
rejectLabel?: string | undefined;
|
|
383
|
+
/** Render without card chrome, e.g. inside an expanded timeline row. */
|
|
384
|
+
bare?: boolean | undefined;
|
|
54
385
|
}) {
|
|
55
386
|
const titleId = useId();
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
387
|
+
const canApprove = review.availableActions.includes("approve") && !!onApprove;
|
|
388
|
+
const canReject = review.availableActions.includes("reject") && !!onReject;
|
|
389
|
+
const actionable = review.status === "pending" && (canApprove || canReject);
|
|
59
390
|
const busy = Boolean(disabled || submitting);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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();
|
|
76
411
|
return (
|
|
77
|
-
<
|
|
78
|
-
|
|
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}
|
|
79
420
|
aria-busy={Boolean(submitting)}
|
|
80
|
-
className="min-w-0 py-4"
|
|
81
421
|
data-og-tool-review=""
|
|
82
422
|
data-review-status={review.status}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
>
|
|
105
|
-
{review.title}
|
|
106
|
-
</h3>
|
|
107
|
-
{review.accountLabel && (
|
|
108
|
-
<p className="m-0 mt-1 break-words text-og-sm text-og-fg-muted">{review.accountLabel}</p>
|
|
109
|
-
)}
|
|
110
|
-
{(statusDetail[review.status] || review.consequence) && (
|
|
111
|
-
<p className="m-0 mt-3 text-og-sm leading-relaxed text-og-fg-muted">
|
|
112
|
-
{statusDetail[review.status] ?? review.consequence}
|
|
113
|
-
</p>
|
|
114
|
-
)}
|
|
115
|
-
{!quiet && (
|
|
116
|
-
<>
|
|
117
|
-
{extraEffects.length > 0 && (
|
|
118
|
-
<ul className="m-0 mt-3 list-none space-y-1 p-0 text-og-sm text-og-fg">
|
|
119
|
-
{extraEffects.map((effect) => (
|
|
120
|
-
<li key={effect}>{effect}</li>
|
|
121
|
-
))}
|
|
122
|
-
</ul>
|
|
123
|
-
)}
|
|
124
|
-
{selection && (
|
|
125
|
-
<div className="mt-4">
|
|
126
|
-
{review.samples && review.samples.length > 0 && (
|
|
127
|
-
<>
|
|
128
|
-
<p className="m-0 mb-1 text-og-xs text-og-fg-muted">
|
|
129
|
-
Examples from this selection
|
|
130
|
-
</p>
|
|
131
|
-
<ul className="m-0 list-none divide-y divide-og-border p-0">
|
|
132
|
-
{review.samples.map((sample) => (
|
|
133
|
-
<li key={sample.id} className="py-2">
|
|
134
|
-
<p className="m-0 break-words text-og-sm text-og-fg">{sample.title}</p>
|
|
135
|
-
{sample.subtitle && (
|
|
136
|
-
<p className="m-0 mt-0.5 break-words text-og-xs text-og-fg-muted">
|
|
137
|
-
{sample.subtitle}
|
|
138
|
-
</p>
|
|
139
|
-
)}
|
|
140
|
-
</li>
|
|
141
|
-
))}
|
|
142
|
-
</ul>
|
|
143
|
-
</>
|
|
144
|
-
)}
|
|
145
|
-
{onViewDetails && review.detailsAvailable && (
|
|
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 ? (
|
|
146
444
|
<button
|
|
147
445
|
type="button"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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"
|
|
151
449
|
>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
450
|
+
{submitting === "approve" ? (
|
|
451
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden="true" />
|
|
452
|
+
) : null}
|
|
453
|
+
{submitting === "approve" ? "Approving…" : review.approveLabel}
|
|
155
454
|
</button>
|
|
156
|
-
)}
|
|
455
|
+
) : null}
|
|
157
456
|
</div>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
type="button"
|
|
173
|
-
data-review-path={field.path}
|
|
174
|
-
onClick={() => onViewDetails(field.path)}
|
|
175
|
-
className="inline-flex min-h-8 max-w-full items-center gap-1 text-left underline decoration-og-border-strong underline-offset-4 hover:decoration-current [@media(pointer:coarse)]:min-h-11"
|
|
176
|
-
>
|
|
177
|
-
<span className="min-w-0 break-words">{field.preview}</span>
|
|
178
|
-
<ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
179
|
-
</button>
|
|
180
|
-
) : (
|
|
181
|
-
field.preview
|
|
182
|
-
)}
|
|
183
|
-
</dd>
|
|
184
|
-
</div>
|
|
185
|
-
))}
|
|
186
|
-
</dl>
|
|
187
|
-
{review.moreFields > 0 && (
|
|
188
|
-
<p className="m-0 mt-2 text-og-xs text-og-fg-muted">
|
|
189
|
-
{review.moreFields} more fields in the full details
|
|
190
|
-
</p>
|
|
191
|
-
)}
|
|
192
|
-
</>
|
|
193
|
-
)}
|
|
194
|
-
{review.detailsAvailable && onViewDetails && (
|
|
195
|
-
<button
|
|
196
|
-
type="button"
|
|
197
|
-
className="mt-1 min-h-8 text-og-xs text-og-fg-muted underline underline-offset-4 hover:text-og-fg [@media(pointer:coarse)]:min-h-11"
|
|
198
|
-
data-review-path=""
|
|
199
|
-
onClick={() => onViewDetails("")}
|
|
200
|
-
>
|
|
201
|
-
View full details
|
|
202
|
-
</button>
|
|
203
|
-
)}
|
|
204
|
-
{actionable && (
|
|
205
|
-
<>
|
|
206
|
-
<p className="m-0 mt-3 text-og-xs leading-relaxed text-og-fg-muted">{review.reason}</p>
|
|
207
|
-
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
208
|
-
<button
|
|
209
|
-
type="button"
|
|
210
|
-
disabled={busy}
|
|
211
|
-
onClick={onApprove}
|
|
212
|
-
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-og-md border border-og-primary-border bg-og-primary px-4 py-2 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"
|
|
213
|
-
>
|
|
214
|
-
{submitting === "approve" && (
|
|
215
|
-
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden="true" />
|
|
216
|
-
)}
|
|
217
|
-
{submitting === "approve" ? "Approving…" : review.approveLabel}
|
|
218
|
-
</button>
|
|
219
|
-
<button
|
|
220
|
-
type="button"
|
|
221
|
-
disabled={busy}
|
|
222
|
-
onClick={onReject}
|
|
223
|
-
className="min-h-9 rounded-og-md px-3 py-2 text-og-sm text-og-fg-muted transition-colors hover:bg-og-fg/5 hover:text-og-fg disabled:opacity-60 [@media(pointer:coarse)]:min-h-11"
|
|
224
|
-
>
|
|
225
|
-
{submitting === "reject" ? "Declining…" : (rejectLabel ?? "Decline")}
|
|
226
|
-
</button>
|
|
227
|
-
</div>
|
|
228
|
-
</>
|
|
229
|
-
)}
|
|
230
|
-
</article>
|
|
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>
|
|
231
471
|
);
|
|
232
472
|
}
|
|
233
473
|
|
|
@@ -237,24 +477,30 @@ export function ToolActionReviewDetails({
|
|
|
237
477
|
path: initialPath = "",
|
|
238
478
|
load,
|
|
239
479
|
onBack,
|
|
480
|
+
manageFocus = true,
|
|
240
481
|
}: {
|
|
241
482
|
review: ToolActionReview;
|
|
242
483
|
path?: string;
|
|
243
484
|
load: ToolReviewDetailsLoader;
|
|
244
485
|
onBack: () => void;
|
|
486
|
+
/** Hosts rendering simultaneous previews can retain their own focus. */
|
|
487
|
+
manageFocus?: boolean;
|
|
245
488
|
}) {
|
|
246
489
|
const [path, setPath] = useState(initialPath);
|
|
490
|
+
const [trail, setTrail] = useState<Array<{ path: string; label: string }>>([]);
|
|
247
491
|
const [offset, setOffset] = useState(0);
|
|
248
492
|
const [page, setPage] = useState<ToolReviewDetailsPage | null>(null);
|
|
249
493
|
const [error, setError] = useState(false);
|
|
250
494
|
const [retry, setRetry] = useState(0);
|
|
251
495
|
const heading = useRef<HTMLHeadingElement>(null);
|
|
496
|
+
const focusedOnce = useRef(false);
|
|
252
497
|
useEffect(() => {
|
|
253
|
-
if (page) {
|
|
498
|
+
if (page && manageFocus && !focusedOnce.current) {
|
|
499
|
+
focusedOnce.current = true;
|
|
254
500
|
heading.current?.focus({ preventScroll: true });
|
|
255
501
|
heading.current?.scrollIntoView({ block: "start" });
|
|
256
502
|
}
|
|
257
|
-
}, [page]);
|
|
503
|
+
}, [page, manageFocus]);
|
|
258
504
|
useEffect(() => {
|
|
259
505
|
let active = true;
|
|
260
506
|
setPage(null);
|
|
@@ -280,12 +526,26 @@ export function ToolActionReviewDetails({
|
|
|
280
526
|
active = false;
|
|
281
527
|
};
|
|
282
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
|
+
};
|
|
283
540
|
return (
|
|
284
|
-
<section
|
|
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
|
+
>
|
|
285
545
|
<button
|
|
286
546
|
type="button"
|
|
287
547
|
onClick={onBack}
|
|
288
|
-
className="mb-
|
|
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"
|
|
289
549
|
>
|
|
290
550
|
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
|
291
551
|
Back to review
|
|
@@ -293,92 +553,95 @@ export function ToolActionReviewDetails({
|
|
|
293
553
|
<h2
|
|
294
554
|
ref={heading}
|
|
295
555
|
tabIndex={-1}
|
|
296
|
-
className="m-0 break-words text-og-lg font-semibold outline-hidden"
|
|
556
|
+
className="m-0 break-words text-og-lg font-semibold outline-hidden [overflow-wrap:anywhere]"
|
|
297
557
|
>
|
|
298
558
|
{review.title}
|
|
299
559
|
</h2>
|
|
300
|
-
{review.accountLabel
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
303
565
|
</p>
|
|
304
|
-
{
|
|
566
|
+
{trail.length > 0 ? (
|
|
305
567
|
<button
|
|
306
568
|
type="button"
|
|
307
|
-
className="min-h-11 text-og-sm underline"
|
|
308
|
-
onClick={
|
|
309
|
-
setPath(initialPath);
|
|
310
|
-
setOffset(0);
|
|
311
|
-
}}
|
|
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}
|
|
312
571
|
>
|
|
313
|
-
|
|
572
|
+
<ArrowLeftIcon className="size-3.5" aria-hidden="true" />
|
|
573
|
+
Back to {trail.at(-1)?.label ?? "all values"}
|
|
314
574
|
</button>
|
|
315
|
-
)}
|
|
575
|
+
) : null}
|
|
316
576
|
{error ? (
|
|
317
|
-
<div role="alert" className="py-
|
|
318
|
-
|
|
577
|
+
<div role="alert" className="mt-4 py-4 text-og-sm">
|
|
578
|
+
Couldn't load these details.{" "}
|
|
319
579
|
<button
|
|
320
580
|
type="button"
|
|
321
581
|
onClick={() => setRetry((value) => value + 1)}
|
|
322
|
-
className="min-h-11 underline"
|
|
582
|
+
className="min-h-11 underline underline-offset-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
|
|
323
583
|
>
|
|
324
584
|
Try again
|
|
325
585
|
</button>
|
|
326
586
|
</div>
|
|
327
587
|
) : !page ? (
|
|
328
|
-
<p role="status" className="py-
|
|
588
|
+
<p role="status" className="mt-4 py-4 text-og-sm text-og-fg-muted">
|
|
329
589
|
Loading details…
|
|
330
590
|
</p>
|
|
331
591
|
) : (
|
|
332
592
|
<>
|
|
333
|
-
<dl className="m-0 divide-y divide-og-border">
|
|
593
|
+
<dl className="m-0 mt-4 divide-y divide-og-border border-y border-og-border">
|
|
334
594
|
{page.items.map((item) => (
|
|
335
|
-
<div key={`${page.path}:${item.label}`} className="py-
|
|
595
|
+
<div key={`${page.path}:${item.label}`} className="py-3">
|
|
336
596
|
<dt className="m-0 text-og-xs text-og-fg-muted">{item.label}</dt>
|
|
337
597
|
<dd className="m-0 mt-1 whitespace-pre-wrap break-words text-og-sm leading-relaxed [overflow-wrap:anywhere]">
|
|
338
|
-
{item.
|
|
339
|
-
{item.truncated && item.path && (
|
|
598
|
+
{item.truncated && item.path ? (
|
|
340
599
|
<button
|
|
341
600
|
type="button"
|
|
342
|
-
aria-label={`Open
|
|
343
|
-
className="
|
|
344
|
-
onClick={() =>
|
|
345
|
-
setPath(item.path!);
|
|
346
|
-
setOffset(0);
|
|
347
|
-
}}
|
|
601
|
+
aria-label={`Open ${item.label}`}
|
|
602
|
+
className={cn(linkButton, "max-w-full")}
|
|
603
|
+
onClick={() => open(item.path!, item.label)}
|
|
348
604
|
>
|
|
349
|
-
|
|
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" />
|
|
350
609
|
</button>
|
|
610
|
+
) : (
|
|
611
|
+
item.value
|
|
351
612
|
)}
|
|
352
613
|
</dd>
|
|
353
614
|
</div>
|
|
354
615
|
))}
|
|
355
616
|
</dl>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
<button
|
|
361
|
-
type="button"
|
|
362
|
-
disabled={offset === 0}
|
|
363
|
-
onClick={() => setOffset(Math.max(0, offset - 25))}
|
|
364
|
-
className="min-h-11 rounded-og-md border border-og-border px-3 disabled:opacity-40"
|
|
365
|
-
>
|
|
366
|
-
Previous
|
|
367
|
-
</button>
|
|
368
|
-
<span className="text-og-fg-muted">
|
|
369
|
-
{page.total === 0
|
|
370
|
-
? "No items"
|
|
371
|
-
: `${offset + 1}–${offset + page.items.length} of ${page.total.toLocaleString()}`}
|
|
372
|
-
</span>
|
|
373
|
-
<button
|
|
374
|
-
type="button"
|
|
375
|
-
disabled={page.nextOffset === null}
|
|
376
|
-
onClick={() => setOffset(page.nextOffset ?? offset)}
|
|
377
|
-
className="min-h-11 rounded-og-md border border-og-border px-3 disabled:opacity-40"
|
|
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"
|
|
378
621
|
>
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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}
|
|
382
645
|
</>
|
|
383
646
|
)}
|
|
384
647
|
</section>
|