@stigmer/react 3.1.13 → 3.1.14

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 (69) hide show
  1. package/agent/AgentDetailView.d.ts +7 -9
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +22 -30
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/index.d.ts +2 -2
  6. package/index.d.ts.map +1 -1
  7. package/index.js +2 -2
  8. package/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/sharing/AgentShareList.d.ts +44 -0
  11. package/sharing/AgentShareList.d.ts.map +1 -0
  12. package/sharing/AgentShareList.js +149 -0
  13. package/sharing/AgentShareList.js.map +1 -0
  14. package/sharing/ShareAgentDialog.d.ts +38 -25
  15. package/sharing/ShareAgentDialog.d.ts.map +1 -1
  16. package/sharing/ShareAgentDialog.js +131 -62
  17. package/sharing/ShareAgentDialog.js.map +1 -1
  18. package/sharing/index.d.ts +10 -8
  19. package/sharing/index.d.ts.map +1 -1
  20. package/sharing/index.js +5 -4
  21. package/sharing/index.js.map +1 -1
  22. package/sharing/useAgentShares.d.ts +42 -0
  23. package/sharing/useAgentShares.d.ts.map +1 -0
  24. package/sharing/useAgentShares.js +38 -0
  25. package/sharing/useAgentShares.js.map +1 -0
  26. package/sharing/useCanCreateAgentShare.d.ts +43 -0
  27. package/sharing/useCanCreateAgentShare.d.ts.map +1 -0
  28. package/sharing/useCanCreateAgentShare.js +44 -0
  29. package/sharing/useCanCreateAgentShare.js.map +1 -0
  30. package/sharing/useDeleteAgentShare.d.ts +42 -0
  31. package/sharing/useDeleteAgentShare.d.ts.map +1 -0
  32. package/sharing/useDeleteAgentShare.js +45 -0
  33. package/sharing/useDeleteAgentShare.js.map +1 -0
  34. package/sharing/useSaveAgentShare.d.ts +43 -18
  35. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  36. package/sharing/useSaveAgentShare.js +55 -22
  37. package/sharing/useSaveAgentShare.js.map +1 -1
  38. package/src/agent/AgentDetailView.tsx +34 -43
  39. package/src/index.ts +11 -9
  40. package/src/sharing/AgentShareList.tsx +507 -0
  41. package/src/sharing/ShareAgentDialog.tsx +310 -112
  42. package/src/sharing/__tests__/AgentShareList.test.tsx +388 -0
  43. package/src/sharing/__tests__/ShareAgentDialog.test.tsx +311 -312
  44. package/src/sharing/__tests__/useAgentShares.test.tsx +145 -0
  45. package/src/sharing/__tests__/useCanCreateAgentShare.test.tsx +190 -0
  46. package/src/sharing/index.ts +10 -12
  47. package/src/sharing/useAgentShares.ts +68 -0
  48. package/src/sharing/useCanCreateAgentShare.ts +74 -0
  49. package/src/sharing/useDeleteAgentShare.ts +73 -0
  50. package/src/sharing/useSaveAgentShare.ts +73 -23
  51. package/styles.css +1 -1
  52. package/sharing/useAgentShare.d.ts +0 -49
  53. package/sharing/useAgentShare.d.ts.map +0 -1
  54. package/sharing/useAgentShare.js +0 -64
  55. package/sharing/useAgentShare.js.map +0 -1
  56. package/sharing/useCreateExternalShareLink.d.ts +0 -90
  57. package/sharing/useCreateExternalShareLink.d.ts.map +0 -1
  58. package/sharing/useCreateExternalShareLink.js +0 -65
  59. package/sharing/useCreateExternalShareLink.js.map +0 -1
  60. package/sharing/useShareAgent.d.ts +0 -69
  61. package/sharing/useShareAgent.d.ts.map +0 -1
  62. package/sharing/useShareAgent.js +0 -42
  63. package/sharing/useShareAgent.js.map +0 -1
  64. package/src/sharing/__tests__/useAgentShare.test.tsx +0 -210
  65. package/src/sharing/__tests__/useCreateExternalShareLink.test.tsx +0 -194
  66. package/src/sharing/__tests__/useShareAgent.test.tsx +0 -131
  67. package/src/sharing/useAgentShare.ts +0 -105
  68. package/src/sharing/useCreateExternalShareLink.tsx +0 -141
  69. package/src/sharing/useShareAgent.tsx +0 -107
@@ -0,0 +1,507 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import {
6
+ appendLinkToken,
7
+ chatPath,
8
+ getUserMessage,
9
+ } from "@stigmer/sdk";
10
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
11
+ import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
12
+ import { toast } from "../feedback/toast.js";
13
+ import { PermissionGate } from "../iam-policy/PermissionGate.js";
14
+ import { ConfirmDialog } from "../resource-detail/ConfirmDialog.js";
15
+ import { useConfirmAction } from "../resource-detail/useConfirmAction.js";
16
+ import { useCopyResource } from "../resource-detail/useCopyResource.js";
17
+ import { ShareAgentDialog } from "./ShareAgentDialog.js";
18
+ import { useAgentShares } from "./useAgentShares.js";
19
+ import { useCanCreateAgentShare } from "./useCanCreateAgentShare.js";
20
+ import { useDeleteAgentShare } from "./useDeleteAgentShare.js";
21
+ import { useRotateShareLink } from "./useRotateShareLink.js";
22
+ import {
23
+ draftFromShare,
24
+ sharingAudienceFromProto,
25
+ useSaveAgentShare,
26
+ } from "./useSaveAgentShare.js";
27
+
28
+ /** Props for {@link AgentShareList}. */
29
+ export interface AgentShareListProps {
30
+ /** The agent whose shares are managed. */
31
+ readonly agent: Agent;
32
+ /**
33
+ * The viewer's active organization slug. A share created from this
34
+ * list lands in this org — its URL, billing, and credentials belong
35
+ * to it (a **cross-org share** when it differs from the agent's org,
36
+ * decision 013). Omit to default to the agent's own org.
37
+ */
38
+ readonly viewerOrg?: string;
39
+ /**
40
+ * Builds the absolute public chat URL for a share. The host
41
+ * application owns URL construction (its configured public origin may
42
+ * differ from the rendering origin — e.g. the desktop app). When
43
+ * omitted, links fall back to the relative `/chat/<org>/<slug>`.
44
+ */
45
+ readonly buildShareUrl?: (org: string, slug: string) => string;
46
+ /** Additional CSS class names. */
47
+ readonly className?: string;
48
+ }
49
+
50
+ /**
51
+ * Management surface for an agent's {@link AgentShare} channels — the
52
+ * agent analog of {@link AgentInstanceList}, rendered in the agent
53
+ * detail view's Shares tab.
54
+ *
55
+ * Lists every share the viewer can see (the server FGA-filters
56
+ * `getByAgent`, so each org's members see their own org's channels and
57
+ * never another org's — decision 013), with per-row copy link, edit,
58
+ * pause/resume, reset link, and delete. Creation goes through the same
59
+ * {@link ShareAgentDialog} and always lands in the viewer's active org;
60
+ * the create bar mirrors the server's via
61
+ * {@link useCanCreateAgentShare}, so the button never appears to a user
62
+ * whose create would be refused.
63
+ *
64
+ * Self-contained: owns its dialog, its confirmation prompts, and its
65
+ * refetch-after-mutation — hosts render it with just the agent and the
66
+ * URL builder.
67
+ *
68
+ * This is an SDK component (DD-001) — embeddable by platform builders.
69
+ */
70
+ export function AgentShareList({
71
+ agent,
72
+ viewerOrg,
73
+ buildShareUrl,
74
+ className,
75
+ }: AgentShareListProps) {
76
+ const agentId = agent.metadata?.id ?? "";
77
+ const { shares, isLoading, error, refetch } = useAgentShares(agentId);
78
+ const { allowed: canCreate, shareOrg } = useCanCreateAgentShare(
79
+ agent,
80
+ viewerOrg,
81
+ );
82
+ const { deleteShare } = useDeleteAgentShare();
83
+ const { confirmState, confirm, handleConfirm, handleCancel } =
84
+ useConfirmAction();
85
+
86
+ // One dialog instance serves both flows: editing the chosen share, or
87
+ // creating a new one in the viewer's org.
88
+ const [editor, setEditor] = useState<
89
+ | { readonly mode: "create" }
90
+ | { readonly mode: "edit"; readonly share: AgentShare }
91
+ | null
92
+ >(null);
93
+
94
+ const handleDelete = useCallback(
95
+ async (share: AgentShare) => {
96
+ const slug = share.metadata?.slug ?? "";
97
+ const confirmed = await confirm({
98
+ title: "Delete share?",
99
+ description:
100
+ `The link /chat/${share.metadata?.org}/${slug} stops working ` +
101
+ "immediately — including for visitors mid-conversation — and its " +
102
+ "configuration (origins, messages, credential bindings) is gone. " +
103
+ "To stop serving while keeping the configuration, pause it instead.",
104
+ confirmLabel: "Delete",
105
+ variant: "destructive",
106
+ });
107
+ if (!confirmed) return;
108
+ try {
109
+ await deleteShare(share.metadata?.id ?? "");
110
+ toast.success("Share deleted");
111
+ refetch();
112
+ } catch (err) {
113
+ toast.error(getUserMessage(err));
114
+ }
115
+ },
116
+ [confirm, deleteShare, refetch],
117
+ );
118
+
119
+ if (isLoading) {
120
+ return <LoadingSkeleton />;
121
+ }
122
+
123
+ if (error) {
124
+ return (
125
+ <div className="py-8 text-center text-sm text-destructive">
126
+ Failed to load shares
127
+ </div>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <div className={cn("space-y-3", className)}>
133
+ {shares.length === 0 ? (
134
+ <ShareEmptyState
135
+ canCreate={canCreate}
136
+ onCreateClick={() => setEditor({ mode: "create" })}
137
+ />
138
+ ) : (
139
+ <>
140
+ <div className="flex items-center justify-between">
141
+ <h3 className="text-sm font-medium text-foreground">
142
+ {shares.length} {shares.length === 1 ? "share" : "shares"}
143
+ </h3>
144
+ {canCreate && (
145
+ <button
146
+ type="button"
147
+ onClick={() => setEditor({ mode: "create" })}
148
+ className={cn(
149
+ "inline-flex items-center gap-1 rounded-md px-2.5 py-1",
150
+ "text-xs font-medium",
151
+ "border border-border text-foreground",
152
+ "hover:bg-accent-hover",
153
+ "focus:outline-none focus:ring-2 focus:ring-ring",
154
+ )}
155
+ >
156
+ <PlusIcon />
157
+ Create share
158
+ </button>
159
+ )}
160
+ </div>
161
+
162
+ <div className="overflow-hidden rounded-lg border border-border">
163
+ <table className="w-full text-sm">
164
+ <thead>
165
+ <tr className="border-b border-border bg-muted-subtle">
166
+ <th className="px-4 py-2 text-left font-medium text-muted-foreground">Name</th>
167
+ <th className="px-4 py-2 text-left font-medium text-muted-foreground">Link</th>
168
+ <th className="px-4 py-2 text-left font-medium text-muted-foreground">Audience</th>
169
+ <th className="px-4 py-2 text-left font-medium text-muted-foreground">Status</th>
170
+ <th className="px-4 py-2 text-right font-medium text-muted-foreground">Actions</th>
171
+ </tr>
172
+ </thead>
173
+ <tbody className="divide-y divide-border">
174
+ {shares.map((share) => (
175
+ <ShareRow
176
+ key={share.metadata?.id}
177
+ share={share}
178
+ agent={agent}
179
+ buildShareUrl={buildShareUrl}
180
+ onEditClick={(s) => setEditor({ mode: "edit", share: s })}
181
+ onDeleteClick={handleDelete}
182
+ refetch={refetch}
183
+ />
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ </>
189
+ )}
190
+
191
+ {editor && (
192
+ <ShareAgentDialog
193
+ open
194
+ onOpenChange={(open) => {
195
+ if (!open) setEditor(null);
196
+ }}
197
+ agent={agent}
198
+ share={editor.mode === "edit" ? editor.share : undefined}
199
+ shareOrg={shareOrg}
200
+ buildShareUrl={buildShareUrl}
201
+ onSharingChanged={refetch}
202
+ />
203
+ )}
204
+
205
+ <ConfirmDialog
206
+ state={confirmState}
207
+ onConfirm={handleConfirm}
208
+ onCancel={handleCancel}
209
+ />
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Row — one share channel
216
+ // ---------------------------------------------------------------------------
217
+
218
+ interface ShareRowProps {
219
+ readonly share: AgentShare;
220
+ readonly agent: Agent;
221
+ readonly buildShareUrl?: (org: string, slug: string) => string;
222
+ readonly onEditClick: (share: AgentShare) => void;
223
+ readonly onDeleteClick: (share: AgentShare) => void;
224
+ readonly refetch: () => void;
225
+ }
226
+
227
+ function ShareRow({
228
+ share,
229
+ agent,
230
+ buildShareUrl,
231
+ onEditClick,
232
+ onDeleteClick,
233
+ refetch,
234
+ }: ShareRowProps) {
235
+ const meta = share.metadata;
236
+ const id = meta?.id ?? "";
237
+ const org = meta?.org ?? "";
238
+ const slug = meta?.slug ?? "";
239
+ const enabled = share.spec?.enabled ?? false;
240
+ const audience = sharingAudienceFromProto(share.spec?.audience);
241
+ const isCrossOrg = org !== (agent.metadata?.org ?? "");
242
+
243
+ const { copy } = useCopyResource();
244
+ const { save, isPending } = useSaveAgentShare(agent);
245
+ const { rotateShareLink, isPending: isRotating } = useRotateShareLink(id);
246
+
247
+ // The copyable URL carries the link token on public shares (org
248
+ // audience is gated by membership, not the token). The display stays
249
+ // the bare path — the token is a secret, not an address.
250
+ const displayPath = chatPath(org, slug);
251
+ const copyUrl = (() => {
252
+ const base = buildShareUrl ? buildShareUrl(org, slug) : displayPath;
253
+ return audience === "org"
254
+ ? base
255
+ : appendLinkToken(base, share.status?.shareLinkToken ?? "");
256
+ })();
257
+
258
+ const handleCopyLink = useCallback(() => {
259
+ void copy(copyUrl, "Link");
260
+ }, [copy, copyUrl]);
261
+
262
+ // Pause/resume is a full-spec save with only `enabled` flipped —
263
+ // draftFromShare guarantees a toggle can never wipe origins, messages,
264
+ // or credential bindings (the fails-closed posture the CLI shares).
265
+ const handleToggleEnabled = useCallback(async () => {
266
+ try {
267
+ await save({ ...draftFromShare(share), enabled: !enabled }, share);
268
+ toast.success(enabled ? "Share paused" : "Share resumed");
269
+ refetch();
270
+ } catch (err) {
271
+ toast.error(getUserMessage(err));
272
+ }
273
+ }, [save, share, enabled, refetch]);
274
+
275
+ const handleResetLink = useCallback(async () => {
276
+ try {
277
+ await rotateShareLink();
278
+ toast.success("Link reset — the old link no longer works");
279
+ refetch();
280
+ } catch (err) {
281
+ toast.error(getUserMessage(err));
282
+ }
283
+ }, [rotateShareLink, refetch]);
284
+
285
+ return (
286
+ <tr
287
+ className="cursor-pointer transition-colors hover:bg-accent-hover"
288
+ onClick={() => onEditClick(share)}
289
+ >
290
+ <td className="px-4 py-2.5">
291
+ <div className="flex items-center gap-2">
292
+ <span className="font-medium text-foreground">
293
+ {meta?.name || slug || "\u2014"}
294
+ </span>
295
+ {isCrossOrg && (
296
+ <span
297
+ className={cn(
298
+ "inline-flex items-center rounded-md px-1.5 py-0.5",
299
+ "text-[0.6rem] font-medium uppercase tracking-wide",
300
+ "bg-muted text-muted-foreground border border-border",
301
+ )}
302
+ title={`This share lives in ${org}; the agent lives in ${agent.metadata?.org}`}
303
+ >
304
+ Cross-org
305
+ </span>
306
+ )}
307
+ </div>
308
+ </td>
309
+
310
+ <td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
311
+ <div className="flex items-center gap-1.5">
312
+ <code
313
+ className="max-w-56 truncate font-mono text-xs text-muted-foreground"
314
+ title={displayPath}
315
+ >
316
+ {displayPath}
317
+ </code>
318
+ <button
319
+ type="button"
320
+ onClick={handleCopyLink}
321
+ aria-label={`Copy link for ${meta?.name || slug}`}
322
+ className={cn(
323
+ "rounded p-1 text-muted-foreground",
324
+ "hover:bg-accent-hover hover:text-foreground",
325
+ "focus:outline-none focus:ring-1 focus:ring-ring",
326
+ )}
327
+ >
328
+ <CopyIcon />
329
+ </button>
330
+ </div>
331
+ </td>
332
+
333
+ <td className="px-4 py-2.5">
334
+ <span className="text-xs text-muted-foreground">
335
+ {audience === "org" ? "Org members" : "Public"}
336
+ </span>
337
+ </td>
338
+
339
+ <td className="px-4 py-2.5">
340
+ <span
341
+ className={cn(
342
+ "inline-flex items-center gap-1.5 text-xs",
343
+ enabled ? "text-foreground" : "text-muted-foreground",
344
+ )}
345
+ >
346
+ <span
347
+ aria-hidden="true"
348
+ className={cn(
349
+ "size-1.5 rounded-full",
350
+ enabled ? "bg-success" : "bg-muted-foreground",
351
+ )}
352
+ />
353
+ {enabled ? "Active" : "Paused"}
354
+ </span>
355
+ </td>
356
+
357
+ <td className="px-4 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
358
+ <div className="flex items-center justify-end gap-1">
359
+ <PermissionGate resource={{ kind: "agent_share", id }} relation="can_edit">
360
+ <button
361
+ type="button"
362
+ onClick={() => onEditClick(share)}
363
+ className={cn(
364
+ "rounded px-2 py-1 text-xs font-medium",
365
+ "text-foreground hover:bg-accent-hover",
366
+ "focus:outline-none focus:ring-1 focus:ring-ring",
367
+ )}
368
+ >
369
+ Edit
370
+ </button>
371
+ <button
372
+ type="button"
373
+ onClick={() => void handleToggleEnabled()}
374
+ disabled={isPending}
375
+ className={cn(
376
+ "rounded px-2 py-1 text-xs font-medium",
377
+ "text-foreground hover:bg-accent-hover",
378
+ "focus:outline-none focus:ring-1 focus:ring-ring",
379
+ "disabled:pointer-events-none disabled:opacity-50",
380
+ )}
381
+ >
382
+ {enabled ? "Pause" : "Resume"}
383
+ </button>
384
+ {audience === "public" && (
385
+ <button
386
+ type="button"
387
+ onClick={() => void handleResetLink()}
388
+ disabled={isRotating}
389
+ className={cn(
390
+ "rounded px-2 py-1 text-xs font-medium",
391
+ "text-foreground hover:bg-accent-hover",
392
+ "focus:outline-none focus:ring-1 focus:ring-ring",
393
+ "disabled:pointer-events-none disabled:opacity-50",
394
+ )}
395
+ >
396
+ {isRotating ? "Resetting…" : "Reset link"}
397
+ </button>
398
+ )}
399
+ </PermissionGate>
400
+ <PermissionGate resource={{ kind: "agent_share", id }} relation="can_delete">
401
+ <button
402
+ type="button"
403
+ onClick={() => onDeleteClick(share)}
404
+ className={cn(
405
+ "rounded px-2 py-1 text-xs font-medium",
406
+ "text-destructive hover:bg-destructive-subtle",
407
+ "focus:outline-none focus:ring-1 focus:ring-ring",
408
+ )}
409
+ >
410
+ Delete
411
+ </button>
412
+ </PermissionGate>
413
+ </div>
414
+ </td>
415
+ </tr>
416
+ );
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Empty state
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function ShareEmptyState({
424
+ canCreate,
425
+ onCreateClick,
426
+ }: {
427
+ readonly canCreate: boolean;
428
+ readonly onCreateClick: () => void;
429
+ }) {
430
+ return (
431
+ <div className="flex flex-col items-center gap-2 py-12 text-center">
432
+ <ShareIcon className="size-10 text-muted-foreground-faint" />
433
+ <p className="text-sm font-medium text-muted-foreground">No shares yet</p>
434
+ <p className="max-w-sm text-xs text-muted-foreground-subtle">
435
+ A share gives this agent a hosted chat link you can send or embed —
436
+ each share is its own channel with its own audience, embed origins,
437
+ and tool credentials.
438
+ </p>
439
+ {canCreate && (
440
+ <button
441
+ type="button"
442
+ onClick={onCreateClick}
443
+ className={cn(
444
+ "mt-2 inline-flex items-center gap-1 rounded-md px-3 py-1.5",
445
+ "text-xs font-medium",
446
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
447
+ "focus:outline-none focus:ring-2 focus:ring-ring",
448
+ )}
449
+ >
450
+ <PlusIcon />
451
+ Create share
452
+ </button>
453
+ )}
454
+ </div>
455
+ );
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Non-happy states + icons
460
+ // ---------------------------------------------------------------------------
461
+
462
+ function LoadingSkeleton() {
463
+ return (
464
+ <div className="space-y-2 py-4">
465
+ {[1, 2, 3].map((i) => (
466
+ <div key={i} className="h-12 animate-pulse rounded-md bg-muted-faint" />
467
+ ))}
468
+ </div>
469
+ );
470
+ }
471
+
472
+ function PlusIcon() {
473
+ return (
474
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
475
+ <path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
476
+ </svg>
477
+ );
478
+ }
479
+
480
+ function CopyIcon() {
481
+ return (
482
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
483
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
484
+ <path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2" />
485
+ </svg>
486
+ );
487
+ }
488
+
489
+ function ShareIcon({ className }: { readonly className?: string }) {
490
+ return (
491
+ <svg
492
+ className={className}
493
+ viewBox="0 0 16 16"
494
+ fill="none"
495
+ stroke="currentColor"
496
+ strokeWidth="1.5"
497
+ strokeLinecap="round"
498
+ strokeLinejoin="round"
499
+ aria-hidden="true"
500
+ >
501
+ <circle cx="4" cy="8" r="2" />
502
+ <circle cx="12" cy="4" r="2" />
503
+ <circle cx="12" cy="12" r="2" />
504
+ <path d="m5.8 7.1 4.4-2.2M5.8 8.9l4.4 2.2" />
505
+ </svg>
506
+ );
507
+ }