@stigmer/react 3.1.7 → 3.1.9

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 (206) hide show
  1. package/agent/AgentDetailView.d.ts +9 -1
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +15 -4
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/environment/EnvironmentListPanel.d.ts.map +1 -1
  6. package/environment/EnvironmentListPanel.js +11 -3
  7. package/environment/EnvironmentListPanel.js.map +1 -1
  8. package/environment/EnvironmentPicker.d.ts +17 -1
  9. package/environment/EnvironmentPicker.d.ts.map +1 -1
  10. package/environment/EnvironmentPicker.js +3 -2
  11. package/environment/EnvironmentPicker.js.map +1 -1
  12. package/environment/EnvironmentVariableEditor.d.ts.map +1 -1
  13. package/environment/EnvironmentVariableEditor.js +9 -3
  14. package/environment/EnvironmentVariableEditor.js.map +1 -1
  15. package/environment/index.d.ts +1 -0
  16. package/environment/index.d.ts.map +1 -1
  17. package/environment/index.js +1 -0
  18. package/environment/index.js.map +1 -1
  19. package/environment/shareRestriction.d.ts +13 -0
  20. package/environment/shareRestriction.d.ts.map +1 -0
  21. package/environment/shareRestriction.js +15 -0
  22. package/environment/shareRestriction.js.map +1 -0
  23. package/execution/ToolCallItem.d.ts.map +1 -1
  24. package/execution/ToolCallItem.js +12 -0
  25. package/execution/ToolCallItem.js.map +1 -1
  26. package/execution/ToolRunGroup.js +4 -0
  27. package/execution/ToolRunGroup.js.map +1 -1
  28. package/execution/WriteBackCard.d.ts +14 -5
  29. package/execution/WriteBackCard.d.ts.map +1 -1
  30. package/execution/WriteBackCard.js +103 -32
  31. package/execution/WriteBackCard.js.map +1 -1
  32. package/execution/WriteBacksWidget.d.ts +1 -1
  33. package/execution/WriteBacksWidget.js +2 -2
  34. package/execution/WriteBacksWidget.js.map +1 -1
  35. package/execution/index.d.ts +2 -0
  36. package/execution/index.d.ts.map +1 -1
  37. package/execution/index.js +1 -0
  38. package/execution/index.js.map +1 -1
  39. package/execution/write-back-utils.d.ts +57 -0
  40. package/execution/write-back-utils.d.ts.map +1 -0
  41. package/execution/write-back-utils.js +81 -0
  42. package/execution/write-back-utils.js.map +1 -0
  43. package/index.d.ts +6 -2
  44. package/index.d.ts.map +1 -1
  45. package/index.js +6 -1
  46. package/index.js.map +1 -1
  47. package/internal/dev/dom-counter.d.ts.map +1 -1
  48. package/internal/dev/dom-counter.js +2 -2
  49. package/internal/dev/dom-counter.js.map +1 -1
  50. package/internal/dev/enabled.d.ts +7 -0
  51. package/internal/dev/enabled.d.ts.map +1 -0
  52. package/internal/dev/enabled.js +38 -0
  53. package/internal/dev/enabled.js.map +1 -0
  54. package/internal/dev/profiler-wrapper.d.ts +3 -3
  55. package/internal/dev/profiler-wrapper.d.ts.map +1 -1
  56. package/internal/dev/profiler-wrapper.js +5 -5
  57. package/internal/dev/profiler-wrapper.js.map +1 -1
  58. package/internal/dev/use-key-stability.d.ts.map +1 -1
  59. package/internal/dev/use-key-stability.js +2 -2
  60. package/internal/dev/use-key-stability.js.map +1 -1
  61. package/internal/dev/use-render-tracer.d.ts +2 -2
  62. package/internal/dev/use-render-tracer.d.ts.map +1 -1
  63. package/internal/dev/use-render-tracer.js +4 -4
  64. package/internal/dev/use-render-tracer.js.map +1 -1
  65. package/internal/dev/use-stream-rate.d.ts +2 -1
  66. package/internal/dev/use-stream-rate.d.ts.map +1 -1
  67. package/internal/dev/use-stream-rate.js +4 -3
  68. package/internal/dev/use-stream-rate.js.map +1 -1
  69. package/library/ResourceVisibilityControl.d.ts +3 -0
  70. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  71. package/library/ResourceVisibilityControl.js +22 -8
  72. package/library/ResourceVisibilityControl.js.map +1 -1
  73. package/library/useUpdateVisibility.d.ts +5 -3
  74. package/library/useUpdateVisibility.d.ts.map +1 -1
  75. package/library/useUpdateVisibility.js +7 -2
  76. package/library/useUpdateVisibility.js.map +1 -1
  77. package/library/visibilityLevels.d.ts +17 -0
  78. package/library/visibilityLevels.d.ts.map +1 -1
  79. package/library/visibilityLevels.js +32 -0
  80. package/library/visibilityLevels.js.map +1 -1
  81. package/package.json +4 -4
  82. package/session/NewSessionViewer.d.ts +11 -1
  83. package/session/NewSessionViewer.d.ts.map +1 -1
  84. package/session/NewSessionViewer.js +28 -8
  85. package/session/NewSessionViewer.js.map +1 -1
  86. package/session/SessionViewer.d.ts +9 -2
  87. package/session/SessionViewer.d.ts.map +1 -1
  88. package/session/SessionViewer.js +26 -12
  89. package/session/SessionViewer.js.map +1 -1
  90. package/session/audience.d.ts +12 -4
  91. package/session/audience.d.ts.map +1 -1
  92. package/session/facets/ChangesTab.d.ts.map +1 -1
  93. package/session/facets/ChangesTab.js +3 -1
  94. package/session/facets/ChangesTab.js.map +1 -1
  95. package/session/useNewSessionFlow.d.ts +12 -0
  96. package/session/useNewSessionFlow.d.ts.map +1 -1
  97. package/session/useNewSessionFlow.js +13 -1
  98. package/session/useNewSessionFlow.js.map +1 -1
  99. package/session/useSessionPageFlow.d.ts +13 -0
  100. package/session/useSessionPageFlow.d.ts.map +1 -1
  101. package/session/useSessionPageFlow.js +7 -2
  102. package/session/useSessionPageFlow.js.map +1 -1
  103. package/sharing/ShareAgentDialog.d.ts +57 -0
  104. package/sharing/ShareAgentDialog.d.ts.map +1 -0
  105. package/sharing/ShareAgentDialog.js +478 -0
  106. package/sharing/ShareAgentDialog.js.map +1 -0
  107. package/sharing/SharedAgentChat.d.ts +84 -0
  108. package/sharing/SharedAgentChat.d.ts.map +1 -0
  109. package/sharing/SharedAgentChat.js +80 -0
  110. package/sharing/SharedAgentChat.js.map +1 -0
  111. package/sharing/index.d.ts +18 -0
  112. package/sharing/index.d.ts.map +1 -0
  113. package/sharing/index.js +12 -0
  114. package/sharing/index.js.map +1 -0
  115. package/sharing/useAgentShare.d.ts +43 -0
  116. package/sharing/useAgentShare.d.ts.map +1 -0
  117. package/sharing/useAgentShare.js +54 -0
  118. package/sharing/useAgentShare.js.map +1 -0
  119. package/sharing/useRotateShareLink.d.ts +45 -0
  120. package/sharing/useRotateShareLink.d.ts.map +1 -0
  121. package/sharing/useRotateShareLink.js +58 -0
  122. package/sharing/useRotateShareLink.js.map +1 -0
  123. package/sharing/useSaveAgentShare.d.ts +105 -0
  124. package/sharing/useSaveAgentShare.d.ts.map +1 -0
  125. package/sharing/useSaveAgentShare.js +97 -0
  126. package/sharing/useSaveAgentShare.js.map +1 -0
  127. package/sharing/useShareAgent.d.ts +69 -0
  128. package/sharing/useShareAgent.d.ts.map +1 -0
  129. package/sharing/useShareAgent.js +42 -0
  130. package/sharing/useShareAgent.js.map +1 -0
  131. package/sharing/useShareToolReadiness.d.ts +52 -0
  132. package/sharing/useShareToolReadiness.d.ts.map +1 -0
  133. package/sharing/useShareToolReadiness.js +89 -0
  134. package/sharing/useShareToolReadiness.js.map +1 -0
  135. package/sharing/useSharedAgentProfile.d.ts +85 -0
  136. package/sharing/useSharedAgentProfile.d.ts.map +1 -0
  137. package/sharing/useSharedAgentProfile.js +73 -0
  138. package/sharing/useSharedAgentProfile.js.map +1 -0
  139. package/src/agent/AgentDetailView.tsx +28 -3
  140. package/src/environment/EnvironmentListPanel.tsx +47 -18
  141. package/src/environment/EnvironmentPicker.tsx +23 -2
  142. package/src/environment/EnvironmentVariableEditor.tsx +27 -17
  143. package/src/environment/__tests__/shareRestriction.test.ts +35 -0
  144. package/src/environment/index.ts +5 -0
  145. package/src/environment/shareRestriction.ts +22 -0
  146. package/src/execution/ToolCallItem.tsx +19 -1
  147. package/src/execution/ToolRunGroup.tsx +4 -0
  148. package/src/execution/WriteBackCard.tsx +285 -88
  149. package/src/execution/WriteBacksWidget.tsx +5 -5
  150. package/src/execution/__tests__/ToolCallItem.test.tsx +51 -0
  151. package/src/execution/__tests__/ToolRunGroup.test.tsx +22 -0
  152. package/src/execution/__tests__/WriteBackCard.test.tsx +64 -10
  153. package/src/execution/__tests__/write-back-utils.test.ts +135 -0
  154. package/src/execution/index.ts +7 -0
  155. package/src/execution/write-back-utils.ts +107 -0
  156. package/src/index.ts +38 -0
  157. package/src/internal/dev/__tests__/use-key-stability.test.ts +4 -0
  158. package/src/internal/dev/__tests__/use-render-tracer.test.ts +4 -0
  159. package/src/internal/dev/dom-counter.ts +2 -3
  160. package/src/internal/dev/enabled.ts +39 -0
  161. package/src/internal/dev/profiler-wrapper.tsx +5 -6
  162. package/src/internal/dev/use-key-stability.ts +2 -3
  163. package/src/internal/dev/use-render-tracer.ts +4 -5
  164. package/src/internal/dev/use-stream-rate.ts +4 -4
  165. package/src/library/ResourceVisibilityControl.tsx +23 -7
  166. package/src/library/__tests__/visibilityLevels.test.ts +33 -0
  167. package/src/library/useUpdateVisibility.ts +9 -3
  168. package/src/library/visibilityLevels.ts +36 -0
  169. package/src/session/NewSessionViewer.tsx +67 -31
  170. package/src/session/SessionViewer.tsx +67 -36
  171. package/src/session/__tests__/audienceWiring.test.tsx +99 -0
  172. package/src/session/__tests__/useNewSessionFlow.test.tsx +75 -1
  173. package/src/session/__tests__/useSessionPageFlow.guest.test.tsx +112 -0
  174. package/src/session/audience.ts +12 -4
  175. package/src/session/facets/ChangesTab.tsx +6 -4
  176. package/src/session/useNewSessionFlow.ts +26 -1
  177. package/src/session/useSessionPageFlow.ts +24 -2
  178. package/src/sharing/ShareAgentDialog.tsx +1489 -0
  179. package/src/sharing/SharedAgentChat.tsx +283 -0
  180. package/src/sharing/__tests__/ShareAgentDialog.test.tsx +926 -0
  181. package/src/sharing/__tests__/SharedAgentChat.test.tsx +222 -0
  182. package/src/sharing/__tests__/useAgentShare.test.tsx +149 -0
  183. package/src/sharing/__tests__/useRotateShareLink.test.tsx +96 -0
  184. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +241 -0
  185. package/src/sharing/__tests__/useShareAgent.test.tsx +131 -0
  186. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +219 -0
  187. package/src/sharing/__tests__/useSharedAgentProfile.test.tsx +201 -0
  188. package/src/sharing/index.ts +32 -0
  189. package/src/sharing/useAgentShare.ts +91 -0
  190. package/src/sharing/useRotateShareLink.ts +81 -0
  191. package/src/sharing/useSaveAgentShare.ts +186 -0
  192. package/src/sharing/useShareAgent.tsx +107 -0
  193. package/src/sharing/useShareToolReadiness.ts +131 -0
  194. package/src/sharing/useSharedAgentProfile.ts +132 -0
  195. package/src/switch/Switch.tsx +97 -0
  196. package/src/switch/__tests__/Switch.test.tsx +107 -0
  197. package/src/switch/index.ts +2 -0
  198. package/styles.css +1 -1
  199. package/switch/Switch.d.ts +47 -0
  200. package/switch/Switch.d.ts.map +1 -0
  201. package/switch/Switch.js +41 -0
  202. package/switch/Switch.js.map +1 -0
  203. package/switch/index.d.ts +3 -0
  204. package/switch/index.d.ts.map +1 -0
  205. package/switch/index.js +2 -0
  206. package/switch/index.js.map +1 -0
@@ -0,0 +1,1489 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useRef, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import {
6
+ MAX_ALLOWED_ORIGINS,
7
+ appendLinkToken,
8
+ buildEmbedSnippet,
9
+ chatPath,
10
+ getUserMessage,
11
+ validateOrigin,
12
+ type ResourceRef,
13
+ } from "@stigmer/sdk";
14
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
15
+ import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
16
+ import type { Environment } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
17
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
18
+ import { Switch } from "../switch/Switch.js";
19
+ import { Tabs, type TabItem } from "../tabs/Tabs.js";
20
+ import { toast } from "../feedback/toast.js";
21
+ import { useCopyResource } from "../resource-detail/useCopyResource.js";
22
+ import { useDeploymentMode } from "../deployment-mode.js";
23
+ import { useBillingAccount } from "../billing/useBillingAccount.js";
24
+ import { formatCreditBalance } from "../billing/format.js";
25
+ import { EnvironmentPicker } from "../environment/EnvironmentPicker.js";
26
+ import { useAgentShare } from "./useAgentShare.js";
27
+ import {
28
+ sharingAudienceFromProto,
29
+ useSaveAgentShare,
30
+ type AgentShareDraft,
31
+ type SharingAudience,
32
+ } from "./useSaveAgentShare.js";
33
+ import { useRotateShareLink } from "./useRotateShareLink.js";
34
+ import { useShareToolReadiness } from "./useShareToolReadiness.js";
35
+
36
+ /** Maximum length of each visitor message (proto: `string.max_len = 300`). */
37
+ const MAX_MESSAGE_LENGTH = 300;
38
+
39
+ const SHARE_TABS: readonly TabItem[] = [
40
+ { id: "link", label: "Link" },
41
+ { id: "embed", label: "Embed" },
42
+ { id: "developer", label: "Developer" },
43
+ ];
44
+
45
+ const PLATFORM_CLIENT_DOCS_URL =
46
+ "https://docs.stigmer.ai/guides/authentication/platform-client/overview";
47
+
48
+ /** Props for {@link ShareAgentDialog}. */
49
+ export interface ShareAgentDialogProps {
50
+ /** Whether the dialog is open. */
51
+ readonly open: boolean;
52
+ /** Called when the dialog should open or close. */
53
+ readonly onOpenChange: (open: boolean) => void;
54
+ /** The agent whose sharing is managed. */
55
+ readonly agent: Agent;
56
+ /**
57
+ * Builds the absolute public chat URL for the shared agent. The host
58
+ * application owns URL construction (its configured public origin may
59
+ * differ from the rendering origin — e.g. the desktop app). When
60
+ * omitted, falls back to the relative path `/chat/<org>/<slug>`.
61
+ */
62
+ readonly buildShareUrl?: (org: string, slug: string) => string;
63
+ /**
64
+ * Called after any sharing change is persisted. Hosts typically pass
65
+ * the agent data hook's `refetch`.
66
+ */
67
+ readonly onSharingChanged?: () => void;
68
+ /**
69
+ * When `false`, renders as an in-flow open dialog instead of a
70
+ * top-layer modal — no `showModal()`, no backdrop, no focus trap.
71
+ * For embedding the dialog in a constrained surface (documentation
72
+ * demos, visual tests). Interactive hosts keep the default.
73
+ * @default true
74
+ */
75
+ readonly modal?: boolean;
76
+ }
77
+
78
+ /**
79
+ * The Share dialog for an agent: toggle sharing, copy the hosted chat
80
+ * link, copy an embeddable snippet, manage allowed embed origins, bind
81
+ * tool credentials for visitors, customize visitor refusal messages,
82
+ * and discover the PlatformClient SDK path.
83
+ *
84
+ * Sharing is a distinct consent from marketplace visibility: visibility
85
+ * governs who can *read* the blueprint; sharing governs who can *chat*
86
+ * with the running agent — billed to the owning org. The dialog states
87
+ * who pays next to the toggle so enabling sharing never surprises.
88
+ *
89
+ * Sharing lives in its own **AgentShare resource** (decision 011), so
90
+ * the dialog loads the agent's canonical share when it opens and every
91
+ * save is an idempotent `apply` of the complete configuration — the
92
+ * first enable creates the share, later edits update it, one code path
93
+ * (see {@link useSaveAgentShare}). The local draft is refreshed from
94
+ * every returned share, so the dialog never drifts from the server.
95
+ *
96
+ * Built on the native `<dialog>` element for focus trapping and escape
97
+ * handling, matching the SDK's modal convention ({@link ManageAccessDialog}).
98
+ * All visual properties flow through `--stgm-*` design tokens.
99
+ *
100
+ * Most hosts mount it via {@link useShareAgent} (a kebab-menu action).
101
+ * Render it directly only when you own the open-state.
102
+ */
103
+ export function ShareAgentDialog({
104
+ open,
105
+ onOpenChange,
106
+ agent,
107
+ buildShareUrl,
108
+ onSharingChanged,
109
+ modal = true,
110
+ }: ShareAgentDialogProps) {
111
+ const dialogRef = useRef<HTMLDialogElement>(null);
112
+
113
+ const handleClose = useCallback(() => {
114
+ dialogRef.current?.close();
115
+ onOpenChange(false);
116
+ }, [onOpenChange]);
117
+
118
+ // Sync native dialog open state (matches the SDK dialog convention).
119
+ // Non-modal hosts pass `open` as a plain attribute instead — the dialog
120
+ // renders in-flow with no top layer to manage.
121
+ const prevOpenRef = useRef(false);
122
+ if (modal && open !== prevOpenRef.current) {
123
+ prevOpenRef.current = open;
124
+ if (open) {
125
+ requestAnimationFrame(() => {
126
+ if (dialogRef.current && !dialogRef.current.open) {
127
+ dialogRef.current.showModal();
128
+ }
129
+ });
130
+ } else if (dialogRef.current?.open) {
131
+ dialogRef.current.close();
132
+ }
133
+ }
134
+
135
+ return (
136
+ <dialog
137
+ ref={dialogRef}
138
+ open={modal ? undefined : open}
139
+ onClose={handleClose}
140
+ className={cn(
141
+ "w-full max-w-lg rounded-xl border border-border bg-popover p-0 shadow-xl",
142
+ modal ? "fixed inset-0 m-auto backdrop:bg-black/50" : "relative",
143
+ )}
144
+ aria-labelledby="share-agent-title"
145
+ >
146
+ {/* Body mounts only while open so the share is re-read per session
147
+ (the draft resets with it) and no fetch fires on a closed dialog. */}
148
+ {open && (
149
+ <ShareAgentDialogBody
150
+ agent={agent}
151
+ buildShareUrl={buildShareUrl}
152
+ onSharingChanged={onSharingChanged}
153
+ onClose={handleClose}
154
+ />
155
+ )}
156
+ </dialog>
157
+ );
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Dialog body — resolves the canonical share, then hands off to the form
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Loads the agent's canonical share and mounts the form once resolved.
166
+ * The split keeps the form's draft seeding synchronous (`useState`
167
+ * initializer from the loaded share) — no hydrate-on-effect, no window
168
+ * where the switch shows a state the server never had.
169
+ */
170
+ function ShareAgentDialogBody({
171
+ agent,
172
+ buildShareUrl,
173
+ onSharingChanged,
174
+ onClose,
175
+ }: {
176
+ readonly agent: Agent;
177
+ readonly buildShareUrl?: (org: string, slug: string) => string;
178
+ readonly onSharingChanged?: () => void;
179
+ readonly onClose: () => void;
180
+ }) {
181
+ const { share, isLoading, error, refetch } = useAgentShare(agent);
182
+
183
+ return (
184
+ <div className="flex flex-col">
185
+ {/* Header */}
186
+ <div className="flex items-start justify-between border-b border-border px-6 py-4">
187
+ <div className="min-w-0">
188
+ <h2
189
+ id="share-agent-title"
190
+ className="text-base font-semibold text-foreground"
191
+ >
192
+ Share
193
+ </h2>
194
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
195
+ {agent.metadata?.name || agent.metadata?.slug}
196
+ </p>
197
+ </div>
198
+ <button
199
+ type="button"
200
+ onClick={onClose}
201
+ aria-label="Close"
202
+ className={cn(
203
+ "rounded-md p-1 text-muted-foreground",
204
+ "hover:text-foreground hover:bg-accent-hover",
205
+ "focus:outline-none focus:ring-2 focus:ring-ring",
206
+ )}
207
+ >
208
+ <CloseIcon />
209
+ </button>
210
+ </div>
211
+
212
+ {isLoading ? (
213
+ <div
214
+ className="px-6 py-10 text-center"
215
+ aria-busy="true"
216
+ aria-label="Loading sharing settings"
217
+ >
218
+ <div className="mx-auto h-4 w-2/3 animate-pulse rounded bg-muted" />
219
+ <div className="mx-auto mt-2 h-3 w-1/2 animate-pulse rounded bg-muted" />
220
+ </div>
221
+ ) : error ? (
222
+ <div className="px-6 py-8 text-center" role="alert">
223
+ <p className="text-sm text-foreground">
224
+ Couldn&apos;t load this agent&apos;s sharing settings.
225
+ </p>
226
+ <p className="mt-1 text-xs text-muted-foreground">
227
+ {getUserMessage(error)}
228
+ </p>
229
+ <button
230
+ type="button"
231
+ onClick={refetch}
232
+ className={cn(
233
+ "mt-3 rounded-md px-3 py-1.5 text-xs font-medium",
234
+ "border border-border text-foreground hover:bg-accent-hover",
235
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
236
+ )}
237
+ >
238
+ Try again
239
+ </button>
240
+ </div>
241
+ ) : (
242
+ <ShareAgentForm
243
+ agent={agent}
244
+ initialShare={share}
245
+ buildShareUrl={buildShareUrl}
246
+ onSharingChanged={onSharingChanged}
247
+ />
248
+ )}
249
+
250
+ {/* Footer */}
251
+ <div className="flex items-center justify-end border-t border-border px-6 py-3">
252
+ <button
253
+ type="button"
254
+ onClick={onClose}
255
+ className={cn(
256
+ "rounded-md px-3 py-1.5 text-sm font-medium",
257
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
258
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
259
+ )}
260
+ >
261
+ Done
262
+ </button>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Form — owns the share draft for the session
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * The dialog's editable projection of a share. `null` (never shared)
274
+ * seeds the same defaults the server would apply on first create, so
275
+ * the form needs no "no share yet" special case beyond the disabled
276
+ * copy fields that `enabled: false` already produces.
277
+ */
278
+ function draftFromShare(share: AgentShare | null): AgentShareDraft {
279
+ const spec = share?.spec;
280
+ return {
281
+ enabled: spec?.enabled ?? false,
282
+ audience: sharingAudienceFromProto(spec?.audience),
283
+ allowedOrigins: spec?.allowedOrigins ?? [],
284
+ messages: {
285
+ rateLimited: spec?.messages?.rateLimited ?? "",
286
+ unavailable: spec?.messages?.unavailable ?? "",
287
+ conversationEnded: spec?.messages?.conversationEnded ?? "",
288
+ },
289
+ environmentRefs: (spec?.environmentRefs ?? []).map((ref) => ({
290
+ org: ref.org,
291
+ slug: ref.slug,
292
+ })),
293
+ };
294
+ }
295
+
296
+ function ShareAgentForm({
297
+ agent,
298
+ initialShare,
299
+ buildShareUrl,
300
+ onSharingChanged,
301
+ }: {
302
+ readonly agent: Agent;
303
+ readonly initialShare: AgentShare | null;
304
+ readonly buildShareUrl?: (org: string, slug: string) => string;
305
+ readonly onSharingChanged?: () => void;
306
+ }) {
307
+ const agentName = agent.metadata?.name || (agent.metadata?.slug ?? "");
308
+
309
+ // The latest server share is the single baseline: the draft, the link
310
+ // token, and the share id for rotation all derive from it. `null`
311
+ // until the first save creates the share.
312
+ const [share, setShare] = useState<AgentShare | null>(initialShare);
313
+ const [draft, setDraft] = useState<AgentShareDraft>(() =>
314
+ draftFromShare(initialShare),
315
+ );
316
+ const [activeTab, setActiveTab] = useState("link");
317
+
318
+ const { save, isPending } = useSaveAgentShare(agent);
319
+ const { rotateShareLink, isPending: isRotating } = useRotateShareLink(
320
+ share?.metadata?.id ?? null,
321
+ );
322
+
323
+ // The share's own org/slug form the hosted URL. Before the first save
324
+ // the agent's stand in — exactly the identity the server will assign
325
+ // on create (D2: share slug defaults to the agent's).
326
+ const org = share?.metadata?.org || (agent.metadata?.org ?? "");
327
+ const slug = share?.metadata?.slug || (agent.metadata?.slug ?? "");
328
+ const linkToken = share?.status?.shareLinkToken ?? "";
329
+
330
+ // Single commit path: apply the complete draft, adopt the server's
331
+ // returned share as the new baseline, notify the host.
332
+ const commit = useCallback(
333
+ async (next: AgentShareDraft, successMessage: string): Promise<boolean> => {
334
+ try {
335
+ const persisted = await save(next, share);
336
+ if (persisted) {
337
+ setShare(persisted);
338
+ setDraft(draftFromShare(persisted));
339
+ } else {
340
+ setDraft(next);
341
+ }
342
+ toast.success(successMessage);
343
+ onSharingChanged?.();
344
+ return true;
345
+ } catch (err) {
346
+ toast.error(getUserMessage(err));
347
+ return false;
348
+ }
349
+ },
350
+ [save, share, onSharingChanged],
351
+ );
352
+
353
+ const handleRotateLink = useCallback(async () => {
354
+ try {
355
+ const updated = await rotateShareLink();
356
+ if (updated) {
357
+ setShare(updated);
358
+ }
359
+ toast.success("Link reset — the old link no longer works");
360
+ onSharingChanged?.();
361
+ } catch (err) {
362
+ toast.error(getUserMessage(err));
363
+ }
364
+ }, [rotateShareLink, onSharingChanged]);
365
+
366
+ const handleToggle = useCallback(
367
+ (enabled: boolean) => {
368
+ void commit(
369
+ { ...draft, enabled },
370
+ enabled ? "Sharing enabled" : "Sharing disabled",
371
+ );
372
+ },
373
+ [commit, draft],
374
+ );
375
+
376
+ const handleAudienceChange = useCallback(
377
+ (audience: SharingAudience) => {
378
+ if (audience === draft.audience) return;
379
+ // Credential bindings are public-audience only (the proto CEL rule
380
+ // rejects them on org shares — decision 011 addendum), so switching
381
+ // to org drops them, and the toast says so: silent config loss is
382
+ // worse than a wordier confirmation.
383
+ const dropsBindings =
384
+ audience === "org" && draft.environmentRefs.length > 0;
385
+ void commit(
386
+ {
387
+ ...draft,
388
+ audience,
389
+ environmentRefs: dropsBindings ? [] : draft.environmentRefs,
390
+ },
391
+ audience === "org"
392
+ ? dropsBindings
393
+ ? "Only organization members can chat now — tool credential bindings were removed (they apply to public links only)"
394
+ : "Only organization members can chat now"
395
+ : "Anyone with the link can chat now",
396
+ );
397
+ },
398
+ [commit, draft],
399
+ );
400
+
401
+ const isOrgAudience = draft.audience === "org";
402
+ // Hosts build the base URL; the token rides it only on public shares
403
+ // (org-audience access is gated by membership, not the link token).
404
+ const baseShareUrl = buildShareUrl
405
+ ? buildShareUrl(org, slug)
406
+ : chatPath(org, slug);
407
+ const shareUrl = isOrgAudience
408
+ ? baseShareUrl
409
+ : appendLinkToken(baseShareUrl, linkToken);
410
+
411
+ return (
412
+ <>
413
+ {/* Sharing master switch — governs every tab, so it sits above them. */}
414
+ <div className="border-b border-border px-6 py-4">
415
+ <div className="flex items-start justify-between gap-4">
416
+ <div className="min-w-0">
417
+ <span
418
+ id="share-enabled-label"
419
+ className="text-sm font-medium text-foreground"
420
+ >
421
+ {isOrgAudience
422
+ ? "Organization members can chat"
423
+ : "Anyone with the link can chat"}
424
+ </span>
425
+ <WhoPaysLine org={org} audience={draft.audience} />
426
+ </div>
427
+ <Switch
428
+ checked={draft.enabled}
429
+ onCheckedChange={handleToggle}
430
+ disabled={isPending}
431
+ aria-labelledby="share-enabled-label"
432
+ />
433
+ </div>
434
+ <AudienceSelector
435
+ audience={draft.audience}
436
+ onChange={handleAudienceChange}
437
+ disabled={isPending}
438
+ />
439
+ <ToolReadinessHint agent={agent} draft={draft} />
440
+ </div>
441
+
442
+ {/* Tabs */}
443
+ <div className="px-6 pb-2 pt-3">
444
+ <Tabs
445
+ tabs={SHARE_TABS}
446
+ activeTab={activeTab}
447
+ onTabChange={setActiveTab}
448
+ aria-label="Sharing options"
449
+ >
450
+ <div className="pt-4">
451
+ {activeTab === "link" && (
452
+ <LinkTab
453
+ shareUrl={shareUrl}
454
+ org={org}
455
+ agent={agent}
456
+ enabled={draft.enabled}
457
+ draft={draft}
458
+ isPending={isPending}
459
+ commit={commit}
460
+ hasLinkToken={linkToken !== ""}
461
+ isRotating={isRotating}
462
+ onResetLink={handleRotateLink}
463
+ />
464
+ )}
465
+ {activeTab === "embed" && (
466
+ <EmbedTab
467
+ shareUrl={shareUrl}
468
+ org={org}
469
+ slug={slug}
470
+ agentName={agentName}
471
+ linkToken={linkToken}
472
+ enabled={draft.enabled}
473
+ draft={draft}
474
+ isPending={isPending}
475
+ commit={commit}
476
+ />
477
+ )}
478
+ {activeTab === "developer" && <DeveloperTab org={org} slug={slug} />}
479
+ </div>
480
+ </Tabs>
481
+ </div>
482
+ </>
483
+ );
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Tool readiness — visitors' tool use needs credentials bound to the share
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /**
491
+ * Pre-flight hint for tool-using agents: visitors' chats receive
492
+ * credentials only from the share's own environment bindings, so a
493
+ * tool-using agent with no bindings (`needs-credentials`) or a binding
494
+ * that is still private (`blocked`) will fail at the visitor's first
495
+ * message. Renders nothing when there is nothing to fix — sharing stays
496
+ * one toggle; this only catches the misconfiguration at share time.
497
+ */
498
+ function ToolReadinessHint({
499
+ agent,
500
+ draft,
501
+ }: {
502
+ readonly agent: Agent;
503
+ readonly draft: AgentShareDraft;
504
+ }) {
505
+ const readiness = useShareToolReadiness(agent, draft);
506
+
507
+ if (readiness.status === "needs-credentials") {
508
+ return (
509
+ <p className="mt-2 text-xs text-warning" role="status">
510
+ Visitors&apos; chats can&apos;t use this agent&apos;s tools yet: no
511
+ credentials are bound to this share. Bind an org-shared environment
512
+ under <span className="font-medium">Tool credentials</span> in the
513
+ Link tab below.
514
+ </p>
515
+ );
516
+ }
517
+
518
+ if (readiness.status !== "blocked") {
519
+ return null;
520
+ }
521
+
522
+ const envList = readiness.privateEnvironments.join(", ");
523
+ const plural = readiness.privateEnvironments.length > 1;
524
+
525
+ return (
526
+ <p className="mt-2 text-xs text-warning" role="status">
527
+ Visitors&apos; chats can&apos;t use this agent&apos;s tools yet: the
528
+ environment{plural ? "s" : ""} <span className="font-medium">{envList}</span>{" "}
529
+ {plural ? "are" : "is"} private. Share {plural ? "them" : "it"} with your
530
+ organization (Settings &rarr; Environments) so visitor runs can use the
531
+ credentials. Secret values stay hidden either way.
532
+ </p>
533
+ );
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Audience — who can chat over the hosted link
538
+ // ---------------------------------------------------------------------------
539
+
540
+ const AUDIENCE_OPTIONS: readonly {
541
+ readonly value: SharingAudience;
542
+ readonly label: string;
543
+ }[] = [
544
+ { value: "public", label: "Public link" },
545
+ { value: "org", label: "Org members" },
546
+ ];
547
+
548
+ /**
549
+ * Segmented control choosing between the two sharing audiences. Mutually
550
+ * exclusive by nature, so a radio group (not toggles); the master switch
551
+ * above stays the single on/off.
552
+ */
553
+ function AudienceSelector({
554
+ audience,
555
+ onChange,
556
+ disabled,
557
+ }: {
558
+ readonly audience: SharingAudience;
559
+ readonly onChange: (audience: SharingAudience) => void;
560
+ readonly disabled: boolean;
561
+ }) {
562
+ return (
563
+ <div
564
+ role="radiogroup"
565
+ aria-label="Who can chat"
566
+ className="mt-3 inline-flex rounded-md border border-border p-0.5"
567
+ >
568
+ {AUDIENCE_OPTIONS.map(({ value, label }) => {
569
+ const selected = audience === value;
570
+ return (
571
+ <button
572
+ key={value}
573
+ type="button"
574
+ role="radio"
575
+ aria-checked={selected}
576
+ onClick={() => onChange(value)}
577
+ disabled={disabled}
578
+ className={cn(
579
+ "rounded px-2.5 py-1 text-xs font-medium",
580
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
581
+ "disabled:pointer-events-none disabled:opacity-50",
582
+ selected
583
+ ? "bg-primary text-primary-foreground"
584
+ : "text-muted-foreground hover:text-foreground",
585
+ )}
586
+ >
587
+ {label}
588
+ </button>
589
+ );
590
+ })}
591
+ </div>
592
+ );
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Who pays — cost attribution is always the org in the share URL
597
+ // ---------------------------------------------------------------------------
598
+
599
+ function WhoPaysLine({
600
+ org,
601
+ audience,
602
+ }: {
603
+ readonly org: string;
604
+ readonly audience: SharingAudience;
605
+ }) {
606
+ const mode = useDeploymentMode();
607
+ // An Organization's id equals its slug (see ApiResourceMetadata.id), so
608
+ // the share's org reference is directly usable as the billing org id.
609
+ // Cloud-only: local mode has no billing accounts.
610
+ const { account } = useBillingAccount(mode === "cloud" ? org : null);
611
+
612
+ const balance =
613
+ account?.balance !== undefined
614
+ ? formatCreditBalance(account.balance?.availableMicros)
615
+ : null;
616
+
617
+ return (
618
+ <p className="mt-0.5 text-xs text-muted-foreground">
619
+ {audience === "org" ? "Members" : "Visitors"} chat on{" "}
620
+ <span className="font-medium">{org}</span>&apos;s credits
621
+ {balance !== null && <> ({balance} available)</>}.
622
+ </p>
623
+ );
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Link tab
628
+ // ---------------------------------------------------------------------------
629
+
630
+ /**
631
+ * Names the reason the copy fields above are grayed out: sharing is off, so
632
+ * the link/embed would lead to NOT_FOUND (the fields stay disabled — handing
633
+ * out a dead link is worse than a disabled button). Points at the master
634
+ * switch as the remedy.
635
+ */
636
+ function SharingOffHint({ subject }: { readonly subject: string }) {
637
+ return (
638
+ <p className="text-xs text-muted-foreground" role="status">
639
+ Sharing is off, so {subject} doesn&apos;t work yet — turn on the switch
640
+ above to activate it.
641
+ </p>
642
+ );
643
+ }
644
+
645
+ function LinkTab({
646
+ shareUrl,
647
+ org,
648
+ agent,
649
+ enabled,
650
+ draft,
651
+ isPending,
652
+ commit,
653
+ hasLinkToken,
654
+ isRotating,
655
+ onResetLink,
656
+ }: {
657
+ readonly shareUrl: string;
658
+ readonly org: string;
659
+ readonly agent: Agent;
660
+ readonly enabled: boolean;
661
+ readonly draft: AgentShareDraft;
662
+ readonly isPending: boolean;
663
+ readonly commit: (
664
+ next: AgentShareDraft,
665
+ successMessage: string,
666
+ ) => Promise<boolean>;
667
+ readonly hasLinkToken: boolean;
668
+ readonly isRotating: boolean;
669
+ readonly onResetLink: () => void;
670
+ }) {
671
+ const isOrgAudience = draft.audience === "org";
672
+
673
+ return (
674
+ <div className="flex flex-col gap-4">
675
+ <CopyField
676
+ label={isOrgAudience ? "Member chat link" : "Public chat link"}
677
+ value={shareUrl}
678
+ copyLabel="Link"
679
+ disabled={!enabled}
680
+ openHref={enabled ? shareUrl : undefined}
681
+ />
682
+ {!enabled && <SharingOffHint subject="this link" />}
683
+
684
+ {isOrgAudience ? (
685
+ <p className="text-xs text-muted-foreground">
686
+ Only signed-in members of <span className="font-medium">{org}</span>{" "}
687
+ can chat. Access is checked on every message, so it ends the moment
688
+ someone leaves the organization. The link is safe to forward — it
689
+ shows nothing to anyone else.
690
+ </p>
691
+ ) : (
692
+ <p className="text-xs text-warning">
693
+ Public links can be forwarded and indexed by search engines.
694
+ Don&apos;t share agents that know internal or confidential
695
+ information — or switch the audience to org members.
696
+ </p>
697
+ )}
698
+
699
+ {!isOrgAudience && (
700
+ <ResetLinkControl
701
+ enabled={enabled}
702
+ hasLinkToken={hasLinkToken}
703
+ isRotating={isRotating}
704
+ onResetLink={onResetLink}
705
+ />
706
+ )}
707
+
708
+ {!isOrgAudience && (
709
+ <ToolCredentialsSection
710
+ org={org}
711
+ agent={agent}
712
+ draft={draft}
713
+ isPending={isPending}
714
+ commit={commit}
715
+ />
716
+ )}
717
+
718
+ <MessagesEditor draft={draft} isPending={isPending} commit={commit} />
719
+ </div>
720
+ );
721
+ }
722
+
723
+ /**
724
+ * The "kill a leaked link" lever: rotating generates a fresh `?k=` token
725
+ * so the old URL stops working immediately — sharing stays on, the agent
726
+ * keeps its name. Public audience only; org shares revoke via membership.
727
+ */
728
+ function ResetLinkControl({
729
+ enabled,
730
+ hasLinkToken,
731
+ isRotating,
732
+ onResetLink,
733
+ }: {
734
+ readonly enabled: boolean;
735
+ readonly hasLinkToken: boolean;
736
+ readonly isRotating: boolean;
737
+ readonly onResetLink: () => void;
738
+ }) {
739
+ return (
740
+ <section className={cn(!enabled && "opacity-50")}>
741
+ <div className="flex items-start justify-between gap-4">
742
+ <div className="min-w-0">
743
+ <h3 className="text-xs font-medium text-muted-foreground">
744
+ Reset link
745
+ </h3>
746
+ <p className="mt-0.5 text-[0.65rem] text-muted-foreground">
747
+ {hasLinkToken
748
+ ? "Generates a new link and kills the current one immediately — even mid-conversation. Re-share the new link with the people who should keep access."
749
+ : "Got forwarded further than you wanted? Resetting locks the link behind a secret and kills the plain address immediately."}
750
+ </p>
751
+ </div>
752
+ <button
753
+ type="button"
754
+ onClick={onResetLink}
755
+ disabled={!enabled || isRotating}
756
+ className={cn(
757
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
758
+ "border border-border text-foreground hover:bg-accent-hover",
759
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
760
+ "disabled:pointer-events-none disabled:opacity-50",
761
+ )}
762
+ >
763
+ {isRotating ? "Resetting…" : "Reset link"}
764
+ </button>
765
+ </div>
766
+ </section>
767
+ );
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Tool credentials — org-shared environments bound to the share for visitors
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /**
775
+ * Binds org-shared environments to the share's `environment_refs` — the
776
+ * consent act that makes a tool-using agent work for visitors (decision
777
+ * 011: credentials belong to the channel, never to the agent's pristine
778
+ * default instance). Public audience only; the section disappears for
779
+ * org shares, whose member sessions carry no share linkage in Phase A.
780
+ *
781
+ * Expanded by default when the agent uses MCP tools — for those agents
782
+ * this is essential configuration, not an advanced option.
783
+ */
784
+ function ToolCredentialsSection({
785
+ org,
786
+ agent,
787
+ draft,
788
+ isPending,
789
+ commit,
790
+ }: {
791
+ readonly org: string;
792
+ readonly agent: Agent;
793
+ readonly draft: AgentShareDraft;
794
+ readonly isPending: boolean;
795
+ readonly commit: (
796
+ next: AgentShareDraft,
797
+ successMessage: string,
798
+ ) => Promise<boolean>;
799
+ }) {
800
+ const hasMcpTools = (agent.spec?.mcpServerUsages?.length ?? 0) > 0;
801
+ const [expanded, setExpanded] = useState(
802
+ hasMcpTools || draft.environmentRefs.length > 0,
803
+ );
804
+
805
+ const handleChange = useCallback(
806
+ (refs: ResourceRef[]) => {
807
+ const added = refs.length > draft.environmentRefs.length;
808
+ void commit(
809
+ { ...draft, environmentRefs: refs },
810
+ added ? "Credentials bound" : "Credential bindings updated",
811
+ );
812
+ },
813
+ [commit, draft],
814
+ );
815
+
816
+ // Only org-shared environments are guest-usable (the runtime merge
817
+ // skips private ones — decision 006), so offering others would bind
818
+ // credentials that silently never apply.
819
+ const onlyOrgShared = useCallback(
820
+ (env: Environment) =>
821
+ env.metadata?.visibility === ApiResourceVisibility.visibility_org,
822
+ [],
823
+ );
824
+
825
+ return (
826
+ <section>
827
+ <button
828
+ type="button"
829
+ onClick={() => setExpanded((v) => !v)}
830
+ aria-expanded={expanded}
831
+ className={cn(
832
+ "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground",
833
+ "hover:text-foreground",
834
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
835
+ )}
836
+ >
837
+ <ChevronIcon
838
+ className={cn("size-3 transition-transform", expanded && "rotate-90")}
839
+ />
840
+ Tool credentials
841
+ </button>
842
+
843
+ {expanded && (
844
+ <div className="mt-2 flex flex-col gap-2">
845
+ <p className="text-[0.65rem] text-muted-foreground">
846
+ Environments whose values visitors&apos; chats can use — bind one
847
+ holding the credentials this agent&apos;s tools need (a read-only
848
+ token is safest). Only environments shared with your organization
849
+ can be bound; share one first in Settings &rarr; Environments.
850
+ Secret values stay hidden from visitors either way.
851
+ </p>
852
+ <EnvironmentPicker
853
+ org={org}
854
+ value={draft.environmentRefs}
855
+ onChange={handleChange}
856
+ disabled={isPending}
857
+ filterEnvironment={onlyOrgShared}
858
+ />
859
+ </div>
860
+ )}
861
+ </section>
862
+ );
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // Embed tab
867
+ // ---------------------------------------------------------------------------
868
+
869
+ /**
870
+ * The app origin the loader is served from — derived from the share URL
871
+ * the host built. Empty when the host never wired `buildShareUrl` (a
872
+ * relative share URL), which degrades {@link buildEmbedSnippet} to a
873
+ * relative `/embed.js` — same degradation as the relative link itself.
874
+ */
875
+ function appOriginFrom(shareUrl: string): string {
876
+ try {
877
+ return new URL(shareUrl).origin;
878
+ } catch {
879
+ return "";
880
+ }
881
+ }
882
+
883
+ /** The no-JavaScript alternative: a plain iframe onto the hosted page. */
884
+ function buildIframeSnippet(shareUrl: string, agentName: string): string {
885
+ return [
886
+ `<iframe`,
887
+ ` src="${shareUrl}"`,
888
+ ` title="${agentName}"`,
889
+ ` width="400"`,
890
+ ` height="600"`,
891
+ ` style="border: 0; border-radius: 12px;"`,
892
+ `></iframe>`,
893
+ ].join("\n");
894
+ }
895
+
896
+ function EmbedTab({
897
+ shareUrl,
898
+ org,
899
+ slug,
900
+ agentName,
901
+ linkToken,
902
+ enabled,
903
+ draft,
904
+ isPending,
905
+ commit,
906
+ }: {
907
+ readonly shareUrl: string;
908
+ readonly org: string;
909
+ readonly slug: string;
910
+ readonly agentName: string;
911
+ readonly linkToken: string;
912
+ readonly enabled: boolean;
913
+ readonly draft: AgentShareDraft;
914
+ readonly isPending: boolean;
915
+ readonly commit: (
916
+ next: AgentShareDraft,
917
+ successMessage: string,
918
+ ) => Promise<boolean>;
919
+ }) {
920
+ const scriptSnippet = useMemo(
921
+ () =>
922
+ buildEmbedSnippet(appOriginFrom(shareUrl), org, slug, linkToken || undefined),
923
+ [shareUrl, org, slug, linkToken],
924
+ );
925
+
926
+ // Embedding serves anonymous visitors via guest tokens, which an
927
+ // org-audience share refuses by design — a sign-in flow inside a
928
+ // third-party iframe is its own project. Say so instead of showing
929
+ // snippets that would render nothing.
930
+ if (draft.audience === "org") {
931
+ return (
932
+ <p className="text-xs text-muted-foreground" role="status">
933
+ Embedding isn&apos;t available for org-members-only sharing: embeds
934
+ serve anonymous visitors, and this agent requires a signed-in
935
+ organization member. Switch the audience to{" "}
936
+ <span className="font-medium">Public link</span> to embed it on a
937
+ site.
938
+ </p>
939
+ );
940
+ }
941
+
942
+ return (
943
+ <div className="flex flex-col gap-4">
944
+ <CopyField
945
+ label="Embed on your site"
946
+ value={scriptSnippet}
947
+ copyLabel="Embed code"
948
+ disabled={!enabled}
949
+ multiline
950
+ />
951
+ {!enabled && <SharingOffHint subject="this embed" />}
952
+ <p className="text-xs text-muted-foreground">
953
+ The widget hides itself on sites that aren&apos;t allowed to embed
954
+ this agent. Free embeds show a &quot;Powered by Stigmer&quot; badge.
955
+ </p>
956
+
957
+ <OriginsEditor draft={draft} isPending={isPending} commit={commit} />
958
+
959
+ <IframeAlternative
960
+ shareUrl={shareUrl}
961
+ agentName={agentName}
962
+ enabled={enabled}
963
+ />
964
+ </div>
965
+ );
966
+ }
967
+
968
+ /**
969
+ * Collapsible fallback for hosts that cannot run scripts (locked-down CMSes,
970
+ * strict sanitizers). Same widget, same origin enforcement — minus the
971
+ * hide-on-refusal behavior the loader provides.
972
+ */
973
+ function IframeAlternative({
974
+ shareUrl,
975
+ agentName,
976
+ enabled,
977
+ }: {
978
+ readonly shareUrl: string;
979
+ readonly agentName: string;
980
+ readonly enabled: boolean;
981
+ }) {
982
+ const [expanded, setExpanded] = useState(false);
983
+ const snippet = useMemo(
984
+ () => buildIframeSnippet(shareUrl, agentName),
985
+ [shareUrl, agentName],
986
+ );
987
+
988
+ return (
989
+ <section>
990
+ <button
991
+ type="button"
992
+ onClick={() => setExpanded((v) => !v)}
993
+ aria-expanded={expanded}
994
+ className={cn(
995
+ "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground",
996
+ "hover:text-foreground",
997
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
998
+ )}
999
+ >
1000
+ <ChevronIcon
1001
+ className={cn("size-3 transition-transform", expanded && "rotate-90")}
1002
+ />
1003
+ No-JavaScript alternative
1004
+ </button>
1005
+
1006
+ {expanded && (
1007
+ <div className="mt-2 flex flex-col gap-2">
1008
+ <p className="text-[0.65rem] text-muted-foreground">
1009
+ For sites that can&apos;t run scripts. A blocked embed shows an
1010
+ empty frame instead of hiding.
1011
+ </p>
1012
+ <CopyField
1013
+ label="Iframe embed"
1014
+ value={snippet}
1015
+ copyLabel="Iframe code"
1016
+ disabled={!enabled}
1017
+ multiline
1018
+ />
1019
+ </div>
1020
+ )}
1021
+ </section>
1022
+ );
1023
+ }
1024
+
1025
+ // ---------------------------------------------------------------------------
1026
+ // Allowed origins editor
1027
+ // ---------------------------------------------------------------------------
1028
+
1029
+ function OriginsEditor({
1030
+ draft,
1031
+ isPending,
1032
+ commit,
1033
+ }: {
1034
+ readonly draft: AgentShareDraft;
1035
+ readonly isPending: boolean;
1036
+ readonly commit: (
1037
+ next: AgentShareDraft,
1038
+ successMessage: string,
1039
+ ) => Promise<boolean>;
1040
+ }) {
1041
+ const [newOrigin, setNewOrigin] = useState("");
1042
+ const [inputError, setInputError] = useState<string | null>(null);
1043
+
1044
+ const handleAdd = useCallback(async () => {
1045
+ const value = newOrigin.trim();
1046
+ const validationError = validateOrigin(value);
1047
+ if (validationError) {
1048
+ setInputError(validationError);
1049
+ return;
1050
+ }
1051
+ if (draft.allowedOrigins.includes(value)) {
1052
+ setInputError("That origin is already in the list");
1053
+ return;
1054
+ }
1055
+ if (draft.allowedOrigins.length >= MAX_ALLOWED_ORIGINS) {
1056
+ setInputError(`At most ${MAX_ALLOWED_ORIGINS} origins are allowed`);
1057
+ return;
1058
+ }
1059
+ setInputError(null);
1060
+ const ok = await commit(
1061
+ { ...draft, allowedOrigins: [...draft.allowedOrigins, value] },
1062
+ "Origin added",
1063
+ );
1064
+ if (ok) setNewOrigin("");
1065
+ }, [newOrigin, draft, commit]);
1066
+
1067
+ const handleRemove = useCallback(
1068
+ (origin: string) => {
1069
+ void commit(
1070
+ {
1071
+ ...draft,
1072
+ allowedOrigins: draft.allowedOrigins.filter((o) => o !== origin),
1073
+ },
1074
+ "Origin removed",
1075
+ );
1076
+ },
1077
+ [draft, commit],
1078
+ );
1079
+
1080
+ return (
1081
+ <section>
1082
+ <h3 className="text-xs font-medium text-muted-foreground">
1083
+ Allowed embed origins
1084
+ </h3>
1085
+ <p className="mt-0.5 text-[0.65rem] text-muted-foreground">
1086
+ Sites allowed to embed this agent. An empty list allows any site;
1087
+ adding origins restricts embedding to those sites. The hosted link
1088
+ works either way.
1089
+ </p>
1090
+
1091
+ {draft.allowedOrigins.length > 0 && (
1092
+ <ul className="mt-2 flex flex-col divide-y divide-border rounded-md border border-border">
1093
+ {draft.allowedOrigins.map((origin) => (
1094
+ <li
1095
+ key={origin}
1096
+ className="flex items-center justify-between gap-2 px-2.5 py-1.5"
1097
+ >
1098
+ <code className="truncate font-mono text-xs text-foreground">
1099
+ {origin}
1100
+ </code>
1101
+ <button
1102
+ type="button"
1103
+ onClick={() => handleRemove(origin)}
1104
+ disabled={isPending}
1105
+ aria-label={`Remove ${origin}`}
1106
+ className={cn(
1107
+ "inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground",
1108
+ "hover:bg-destructive-subtle hover:text-destructive",
1109
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1110
+ "disabled:pointer-events-none disabled:opacity-50",
1111
+ )}
1112
+ >
1113
+ <RemoveIcon />
1114
+ </button>
1115
+ </li>
1116
+ ))}
1117
+ </ul>
1118
+ )}
1119
+
1120
+ <div className="mt-2 flex items-start gap-1.5">
1121
+ <div className="min-w-0 flex-1">
1122
+ <input
1123
+ type="text"
1124
+ value={newOrigin}
1125
+ onChange={(e) => {
1126
+ setNewOrigin(e.target.value);
1127
+ if (inputError) setInputError(null);
1128
+ }}
1129
+ onKeyDown={(e) => {
1130
+ if (e.key === "Enter") {
1131
+ e.preventDefault();
1132
+ void handleAdd();
1133
+ }
1134
+ }}
1135
+ placeholder="https://example.com"
1136
+ aria-label="Add allowed origin"
1137
+ aria-invalid={inputError != null}
1138
+ className={cn(
1139
+ "w-full rounded-md border border-border bg-input-bg px-2 py-1.5 font-mono text-xs text-foreground",
1140
+ "focus:outline-none focus:ring-2 focus:ring-ring",
1141
+ )}
1142
+ />
1143
+ {inputError && (
1144
+ <p role="alert" className="mt-1 text-xs text-destructive">
1145
+ {inputError}
1146
+ </p>
1147
+ )}
1148
+ </div>
1149
+ <button
1150
+ type="button"
1151
+ onClick={() => void handleAdd()}
1152
+ disabled={isPending || !newOrigin.trim()}
1153
+ className={cn(
1154
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
1155
+ "border border-border text-foreground hover:bg-accent-hover",
1156
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1157
+ "disabled:pointer-events-none disabled:opacity-50",
1158
+ )}
1159
+ >
1160
+ Add
1161
+ </button>
1162
+ </div>
1163
+ </section>
1164
+ );
1165
+ }
1166
+
1167
+ // ---------------------------------------------------------------------------
1168
+ // Visitor messages editor (collapsible)
1169
+ // ---------------------------------------------------------------------------
1170
+
1171
+ const MESSAGE_FIELDS = [
1172
+ {
1173
+ key: "rateLimited",
1174
+ label: "Rate limited",
1175
+ placeholder: "Shown when a visitor sends messages too quickly",
1176
+ },
1177
+ {
1178
+ key: "unavailable",
1179
+ label: "Unavailable",
1180
+ placeholder: "Shown when the agent is temporarily unavailable",
1181
+ },
1182
+ {
1183
+ key: "conversationEnded",
1184
+ label: "Conversation ended",
1185
+ placeholder: "Shown when a conversation reaches its limit",
1186
+ },
1187
+ ] as const;
1188
+
1189
+ function MessagesEditor({
1190
+ draft,
1191
+ isPending,
1192
+ commit,
1193
+ }: {
1194
+ readonly draft: AgentShareDraft;
1195
+ readonly isPending: boolean;
1196
+ readonly commit: (
1197
+ next: AgentShareDraft,
1198
+ successMessage: string,
1199
+ ) => Promise<boolean>;
1200
+ }) {
1201
+ const [expanded, setExpanded] = useState(false);
1202
+ const [localMessages, setLocalMessages] = useState(draft.messages);
1203
+
1204
+ const isDirty =
1205
+ localMessages.rateLimited !== draft.messages.rateLimited ||
1206
+ localMessages.unavailable !== draft.messages.unavailable ||
1207
+ localMessages.conversationEnded !== draft.messages.conversationEnded;
1208
+
1209
+ const handleSave = useCallback(() => {
1210
+ void commit({ ...draft, messages: localMessages }, "Messages saved");
1211
+ }, [commit, draft, localMessages]);
1212
+
1213
+ return (
1214
+ <section>
1215
+ <button
1216
+ type="button"
1217
+ onClick={() => setExpanded((v) => !v)}
1218
+ aria-expanded={expanded}
1219
+ className={cn(
1220
+ "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground",
1221
+ "hover:text-foreground",
1222
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
1223
+ )}
1224
+ >
1225
+ <ChevronIcon
1226
+ className={cn("size-3 transition-transform", expanded && "rotate-90")}
1227
+ />
1228
+ Customize visitor messages
1229
+ </button>
1230
+
1231
+ {expanded && (
1232
+ <div className="mt-2 flex flex-col gap-3">
1233
+ <p className="text-[0.65rem] text-muted-foreground">
1234
+ Shown to visitors when a limit is reached. Leave empty to use the
1235
+ platform defaults.
1236
+ </p>
1237
+ {MESSAGE_FIELDS.map(({ key, label, placeholder }) => (
1238
+ <MessageField
1239
+ key={key}
1240
+ label={label}
1241
+ placeholder={placeholder}
1242
+ value={localMessages[key]}
1243
+ onChange={(value) =>
1244
+ setLocalMessages((prev) => ({ ...prev, [key]: value }))
1245
+ }
1246
+ />
1247
+ ))}
1248
+ {isDirty && (
1249
+ <div className="flex items-center justify-end gap-1.5">
1250
+ <button
1251
+ type="button"
1252
+ onClick={() => setLocalMessages(draft.messages)}
1253
+ disabled={isPending}
1254
+ className={cn(
1255
+ "rounded-md px-2.5 py-1 text-xs font-medium",
1256
+ "border border-border text-foreground hover:bg-accent-hover",
1257
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1258
+ )}
1259
+ >
1260
+ Reset
1261
+ </button>
1262
+ <button
1263
+ type="button"
1264
+ onClick={handleSave}
1265
+ disabled={isPending}
1266
+ className={cn(
1267
+ "rounded-md px-2.5 py-1 text-xs font-medium",
1268
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
1269
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1270
+ "disabled:pointer-events-none disabled:opacity-50",
1271
+ )}
1272
+ >
1273
+ Save messages
1274
+ </button>
1275
+ </div>
1276
+ )}
1277
+ </div>
1278
+ )}
1279
+ </section>
1280
+ );
1281
+ }
1282
+
1283
+ function MessageField({
1284
+ label,
1285
+ placeholder,
1286
+ value,
1287
+ onChange,
1288
+ }: {
1289
+ readonly label: string;
1290
+ readonly placeholder: string;
1291
+ readonly value: string;
1292
+ readonly onChange: (value: string) => void;
1293
+ }) {
1294
+ return (
1295
+ <label className="flex flex-col gap-1">
1296
+ <span className="flex items-center justify-between text-xs font-medium text-muted-foreground">
1297
+ {label}
1298
+ <span className="tabular-nums text-[0.65rem]">
1299
+ {value.length}/{MAX_MESSAGE_LENGTH}
1300
+ </span>
1301
+ </span>
1302
+ <textarea
1303
+ value={value}
1304
+ onChange={(e) => onChange(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
1305
+ placeholder={placeholder}
1306
+ rows={2}
1307
+ maxLength={MAX_MESSAGE_LENGTH}
1308
+ className={cn(
1309
+ "w-full resize-y rounded-md border border-border bg-input-bg px-2 py-1.5 text-xs text-foreground",
1310
+ "focus:outline-none focus:ring-2 focus:ring-ring",
1311
+ )}
1312
+ />
1313
+ </label>
1314
+ );
1315
+ }
1316
+
1317
+ // ---------------------------------------------------------------------------
1318
+ // Developer tab — the graduation ladder to the full SDK
1319
+ // ---------------------------------------------------------------------------
1320
+
1321
+ function buildDeveloperSnippet(org: string, slug: string): string {
1322
+ return [
1323
+ `import { createPlatformClientAuth } from "@stigmer/sdk/node";`,
1324
+ ``,
1325
+ `const auth = createPlatformClientAuth({`,
1326
+ ` baseUrl: "https://api.stigmer.ai",`,
1327
+ ` clientId: process.env.STIGMER_CLIENT_ID,`,
1328
+ ` clientSecret: process.env.STIGMER_CLIENT_SECRET,`,
1329
+ `});`,
1330
+ ``,
1331
+ `// Mint tokens for your own users, then chat with ${org}/${slug}`,
1332
+ `const { accessToken } = await auth.mintUserToken({`,
1333
+ ` userId: "user-123",`,
1334
+ `});`,
1335
+ ].join("\n");
1336
+ }
1337
+
1338
+ function DeveloperTab({
1339
+ org,
1340
+ slug,
1341
+ }: {
1342
+ readonly org: string;
1343
+ readonly slug: string;
1344
+ }) {
1345
+ const snippet = useMemo(() => buildDeveloperSnippet(org, slug), [org, slug]);
1346
+
1347
+ return (
1348
+ <div className="flex flex-col gap-3">
1349
+ <p className="text-xs text-muted-foreground">
1350
+ Need your own auth, branding, or UI? Create a platform client and
1351
+ integrate this agent with the Stigmer SDK — the same runtime, full
1352
+ control.
1353
+ </p>
1354
+ <CopyField
1355
+ label="Platform client integration"
1356
+ value={snippet}
1357
+ copyLabel="Code"
1358
+ multiline
1359
+ />
1360
+ <a
1361
+ href={PLATFORM_CLIENT_DOCS_URL}
1362
+ target="_blank"
1363
+ rel="noreferrer"
1364
+ className={cn(
1365
+ "text-xs font-medium text-primary hover:text-primary-muted",
1366
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
1367
+ )}
1368
+ >
1369
+ Read the platform client guide →
1370
+ </a>
1371
+ </div>
1372
+ );
1373
+ }
1374
+
1375
+ // ---------------------------------------------------------------------------
1376
+ // Copy field — labeled value with copy (and optional open) affordances
1377
+ // ---------------------------------------------------------------------------
1378
+
1379
+ function CopyField({
1380
+ label,
1381
+ value,
1382
+ copyLabel,
1383
+ disabled,
1384
+ multiline,
1385
+ openHref,
1386
+ }: {
1387
+ readonly label: string;
1388
+ readonly value: string;
1389
+ readonly copyLabel: string;
1390
+ readonly disabled?: boolean;
1391
+ readonly multiline?: boolean;
1392
+ readonly openHref?: string;
1393
+ }) {
1394
+ const { copy } = useCopyResource();
1395
+
1396
+ return (
1397
+ <section className={cn(disabled && "opacity-50")}>
1398
+ <h3 className="text-xs font-medium text-muted-foreground">{label}</h3>
1399
+ <div className="mt-1.5 flex items-start gap-1.5">
1400
+ {multiline ? (
1401
+ <pre
1402
+ className={cn(
1403
+ "min-w-0 flex-1 overflow-x-auto rounded-md border border-border bg-muted-subtle px-2.5 py-2",
1404
+ "font-mono text-xs leading-relaxed text-foreground",
1405
+ )}
1406
+ >
1407
+ {value}
1408
+ </pre>
1409
+ ) : (
1410
+ <code
1411
+ className={cn(
1412
+ "min-w-0 flex-1 truncate rounded-md border border-border bg-muted-subtle px-2.5 py-1.5",
1413
+ "font-mono text-xs text-foreground",
1414
+ )}
1415
+ title={value}
1416
+ >
1417
+ {value}
1418
+ </code>
1419
+ )}
1420
+ <div className="flex shrink-0 flex-col gap-1.5">
1421
+ <button
1422
+ type="button"
1423
+ onClick={() => void copy(value, copyLabel)}
1424
+ disabled={disabled}
1425
+ className={cn(
1426
+ "rounded-md px-2.5 py-1.5 text-xs font-medium",
1427
+ "border border-border text-foreground hover:bg-accent-hover",
1428
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1429
+ "disabled:pointer-events-none disabled:opacity-50",
1430
+ )}
1431
+ >
1432
+ Copy
1433
+ </button>
1434
+ {openHref && (
1435
+ <a
1436
+ href={openHref}
1437
+ target="_blank"
1438
+ rel="noreferrer"
1439
+ className={cn(
1440
+ "rounded-md px-2.5 py-1.5 text-center text-xs font-medium",
1441
+ "border border-border text-foreground hover:bg-accent-hover",
1442
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1443
+ )}
1444
+ >
1445
+ Open
1446
+ </a>
1447
+ )}
1448
+ </div>
1449
+ </div>
1450
+ </section>
1451
+ );
1452
+ }
1453
+
1454
+ // ---------------------------------------------------------------------------
1455
+ // Icons — inline SVGs following the SDK pattern (no icon library dependency)
1456
+ // ---------------------------------------------------------------------------
1457
+
1458
+ function CloseIcon() {
1459
+ return (
1460
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1461
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
1462
+ </svg>
1463
+ );
1464
+ }
1465
+
1466
+ function RemoveIcon() {
1467
+ return (
1468
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
1469
+ <path d="m4 4 8 8M12 4l-8 8" />
1470
+ </svg>
1471
+ );
1472
+ }
1473
+
1474
+ function ChevronIcon({ className }: { readonly className?: string }) {
1475
+ return (
1476
+ <svg
1477
+ className={className}
1478
+ viewBox="0 0 16 16"
1479
+ fill="none"
1480
+ stroke="currentColor"
1481
+ strokeWidth="1.5"
1482
+ strokeLinecap="round"
1483
+ strokeLinejoin="round"
1484
+ aria-hidden="true"
1485
+ >
1486
+ <path d="m6 3 5 5-5 5" />
1487
+ </svg>
1488
+ );
1489
+ }