@stigmer/react 3.1.6 → 3.1.8

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