@stigmer/react 3.1.12 → 3.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/agent/AgentDetailView.d.ts +11 -3
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +22 -17
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/index.d.ts +2 -2
  6. package/index.d.ts.map +1 -1
  7. package/index.js +2 -2
  8. package/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/sharing/AgentShareList.d.ts +44 -0
  11. package/sharing/AgentShareList.d.ts.map +1 -0
  12. package/sharing/AgentShareList.js +149 -0
  13. package/sharing/AgentShareList.js.map +1 -0
  14. package/sharing/ShareAgentDialog.d.ts +38 -15
  15. package/sharing/ShareAgentDialog.d.ts.map +1 -1
  16. package/sharing/ShareAgentDialog.js +135 -57
  17. package/sharing/ShareAgentDialog.js.map +1 -1
  18. package/sharing/index.d.ts +10 -6
  19. package/sharing/index.d.ts.map +1 -1
  20. package/sharing/index.js +5 -3
  21. package/sharing/index.js.map +1 -1
  22. package/sharing/useAgentShares.d.ts +42 -0
  23. package/sharing/useAgentShares.d.ts.map +1 -0
  24. package/sharing/useAgentShares.js +38 -0
  25. package/sharing/useAgentShares.js.map +1 -0
  26. package/sharing/useCanCreateAgentShare.d.ts +43 -0
  27. package/sharing/useCanCreateAgentShare.d.ts.map +1 -0
  28. package/sharing/useCanCreateAgentShare.js +44 -0
  29. package/sharing/useCanCreateAgentShare.js.map +1 -0
  30. package/sharing/useDeleteAgentShare.d.ts +42 -0
  31. package/sharing/useDeleteAgentShare.d.ts.map +1 -0
  32. package/sharing/useDeleteAgentShare.js +45 -0
  33. package/sharing/useDeleteAgentShare.js.map +1 -0
  34. package/sharing/useSaveAgentShare.d.ts +45 -14
  35. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  36. package/sharing/useSaveAgentShare.js +59 -19
  37. package/sharing/useSaveAgentShare.js.map +1 -1
  38. package/src/agent/AgentDetailView.tsx +37 -20
  39. package/src/index.ts +11 -6
  40. package/src/sharing/AgentShareList.tsx +507 -0
  41. package/src/sharing/ShareAgentDialog.tsx +325 -95
  42. package/src/sharing/__tests__/AgentShareList.test.tsx +388 -0
  43. package/src/sharing/__tests__/ShareAgentDialog.test.tsx +333 -286
  44. package/src/sharing/__tests__/{useAgentShare.test.tsx → useAgentShares.test.tsx} +47 -51
  45. package/src/sharing/__tests__/useCanCreateAgentShare.test.tsx +190 -0
  46. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +48 -0
  47. package/src/sharing/index.ts +10 -7
  48. package/src/sharing/useAgentShares.ts +68 -0
  49. package/src/sharing/useCanCreateAgentShare.ts +74 -0
  50. package/src/sharing/useDeleteAgentShare.ts +73 -0
  51. package/src/sharing/useSaveAgentShare.ts +77 -19
  52. package/styles.css +1 -1
  53. package/sharing/useAgentShare.d.ts +0 -43
  54. package/sharing/useAgentShare.d.ts.map +0 -1
  55. package/sharing/useAgentShare.js +0 -54
  56. package/sharing/useAgentShare.js.map +0 -1
  57. package/sharing/useShareAgent.d.ts +0 -69
  58. package/sharing/useShareAgent.d.ts.map +0 -1
  59. package/sharing/useShareAgent.js +0 -42
  60. package/sharing/useShareAgent.js.map +0 -1
  61. package/src/sharing/__tests__/useShareAgent.test.tsx +0 -131
  62. package/src/sharing/useAgentShare.ts +0 -91
  63. package/src/sharing/useShareAgent.tsx +0 -107
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useMemo, useRef, useState, type FormEvent } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import {
6
6
  MAX_ALLOWED_ORIGINS,
7
+ StigmerError,
7
8
  appendLinkToken,
8
9
  buildEmbedSnippet,
9
10
  chatPath,
@@ -11,10 +12,12 @@ import {
11
12
  validateOrigin,
12
13
  type ResourceRef,
13
14
  } from "@stigmer/sdk";
15
+ import { create as createMessage } from "@bufbuild/protobuf";
14
16
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
15
17
  import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
16
18
  import type { Environment } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
17
19
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
20
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
18
21
  import { Switch } from "../switch/Switch.js";
19
22
  import { Tabs, type TabItem } from "../tabs/Tabs.js";
20
23
  import { toast } from "../feedback/toast.js";
@@ -23,9 +26,10 @@ import { useDeploymentMode } from "../deployment-mode.js";
23
26
  import { useBillingAccount } from "../billing/useBillingAccount.js";
24
27
  import { formatCreditBalance } from "../billing/format.js";
25
28
  import { EnvironmentPicker } from "../environment/EnvironmentPicker.js";
26
- import { useAgentShare } from "./useAgentShare.js";
29
+ import { generateSlug } from "../internal/slug.js";
30
+ import { getFieldError, validateMessage } from "../internal/validate.js";
27
31
  import {
28
- sharingAudienceFromProto,
32
+ draftFromShare,
29
33
  useSaveAgentShare,
30
34
  type AgentShareDraft,
31
35
  type SharingAudience,
@@ -53,6 +57,24 @@ export interface ShareAgentDialogProps {
53
57
  readonly onOpenChange: (open: boolean) => void;
54
58
  /** The agent whose sharing is managed. */
55
59
  readonly agent: Agent;
60
+ /**
61
+ * The exact share to edit. When omitted, the dialog opens in **create
62
+ * mode**: a name/slug step creates a new share in `shareOrg`, then the
63
+ * dialog becomes its editor. The share's identity is immutable once
64
+ * created (decision 011 D2) — edit mode never renames.
65
+ */
66
+ readonly share?: AgentShare | null;
67
+ /**
68
+ * The org that owns (and pays for) a share created by this dialog.
69
+ * Defaults to the agent's own org — the owner's channel. Pass the
70
+ * viewer's org to create a **cross-org share** of another org's
71
+ * marketplace-public agent (decision 013): the share, its billing,
72
+ * its credentials, and its hosted URL all belong to this org, while
73
+ * the agent stays live in its own. Cross-org shares are
74
+ * public-audience only, so the audience selector is hidden in that
75
+ * mode. Ignored in edit mode — an existing share knows its org.
76
+ */
77
+ readonly shareOrg?: string;
56
78
  /**
57
79
  * Builds the absolute public chat URL for the shared agent. The host
58
80
  * application owns URL construction (its configured public origin may
@@ -62,7 +84,7 @@ export interface ShareAgentDialogProps {
62
84
  readonly buildShareUrl?: (org: string, slug: string) => string;
63
85
  /**
64
86
  * Called after any sharing change is persisted. Hosts typically pass
65
- * the agent data hook's `refetch`.
87
+ * the share list's `refetch`.
66
88
  */
67
89
  readonly onSharingChanged?: () => void;
68
90
  /**
@@ -76,34 +98,40 @@ export interface ShareAgentDialogProps {
76
98
  }
77
99
 
78
100
  /**
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.
101
+ * The Share dialog for one agent share: create it (name/slug step), then
102
+ * toggle serving, copy the hosted chat link, copy an embeddable snippet,
103
+ * manage allowed embed origins, bind tool credentials for visitors,
104
+ * customize visitor refusal messages, and discover the PlatformClient
105
+ * SDK path.
83
106
  *
84
107
  * Sharing is a distinct consent from marketplace visibility: visibility
85
108
  * 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.
109
+ * with the running agent — billed to the org that owns the share. The
110
+ * dialog states who pays before creation and next to the toggle, so
111
+ * sharing never surprises.
88
112
  *
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.
113
+ * Sharing lives in its own **AgentShare resource** (decision 011), and
114
+ * an agent can carry N shares each its own channel with its own URL,
115
+ * audience, origins, credentials, and link token (D3). The dialog edits
116
+ * exactly the share it is given; it never resolves "the" share of an
117
+ * agent. Every save is an idempotent `apply` of the complete
118
+ * configuration ({@link useSaveAgentShare}), and the local draft is
119
+ * refreshed from every returned share, so the dialog never drifts from
120
+ * the server.
95
121
  *
96
122
  * Built on the native `<dialog>` element for focus trapping and escape
97
123
  * handling, matching the SDK's modal convention ({@link ManageAccessDialog}).
98
124
  * All visual properties flow through `--stgm-*` design tokens.
99
125
  *
100
- * Most hosts mount it via {@link useShareAgent} (a kebab-menu action).
126
+ * Most hosts mount it via {@link AgentShareList} (the Shares tab).
101
127
  * Render it directly only when you own the open-state.
102
128
  */
103
129
  export function ShareAgentDialog({
104
130
  open,
105
131
  onOpenChange,
106
132
  agent,
133
+ share,
134
+ shareOrg,
107
135
  buildShareUrl,
108
136
  onSharingChanged,
109
137
  modal = true,
@@ -143,11 +171,13 @@ export function ShareAgentDialog({
143
171
  )}
144
172
  aria-labelledby="share-agent-title"
145
173
  >
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. */}
174
+ {/* Body mounts only while open so its draft state resets per
175
+ session reopening the dialog never shows a stale draft. */}
148
176
  {open && (
149
177
  <ShareAgentDialogBody
150
178
  agent={agent}
179
+ share={share ?? null}
180
+ shareOrg={shareOrg}
151
181
  buildShareUrl={buildShareUrl}
152
182
  onSharingChanged={onSharingChanged}
153
183
  onClose={handleClose}
@@ -158,27 +188,39 @@ export function ShareAgentDialog({
158
188
  }
159
189
 
160
190
  // ---------------------------------------------------------------------------
161
- // Dialog body — resolves the canonical share, then hands off to the form
191
+ // Dialog body — create step or editor, depending on the share it was given
162
192
  // ---------------------------------------------------------------------------
163
193
 
164
194
  /**
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.
195
+ * Renders the create step (no share yet) or the editor (share in hand).
196
+ * A share persisted by the create step is adopted locally, so one dialog
197
+ * session flows create edit without remounting and the editor's
198
+ * draft seeding stays synchronous (`useState` initializer from the
199
+ * share): no hydrate-on-effect, no window where the switch shows a state
200
+ * the server never had.
169
201
  */
170
202
  function ShareAgentDialogBody({
171
203
  agent,
204
+ share,
205
+ shareOrg,
172
206
  buildShareUrl,
173
207
  onSharingChanged,
174
208
  onClose,
175
209
  }: {
176
210
  readonly agent: Agent;
211
+ readonly share: AgentShare | null;
212
+ readonly shareOrg?: string;
177
213
  readonly buildShareUrl?: (org: string, slug: string) => string;
178
214
  readonly onSharingChanged?: () => void;
179
215
  readonly onClose: () => void;
180
216
  }) {
181
- const { share, isLoading, error, refetch } = useAgentShare(agent);
217
+ const [created, setCreated] = useState<AgentShare | null>(null);
218
+ const activeShare = share ?? created;
219
+ const isCreating = activeShare === null;
220
+
221
+ const resolvedShareOrg =
222
+ activeShare?.metadata?.org || shareOrg || (agent.metadata?.org ?? "");
223
+ const isCrossOrg = resolvedShareOrg !== (agent.metadata?.org ?? "");
182
224
 
183
225
  return (
184
226
  <div className="flex flex-col">
@@ -189,10 +231,14 @@ function ShareAgentDialogBody({
189
231
  id="share-agent-title"
190
232
  className="text-base font-semibold text-foreground"
191
233
  >
192
- Share
234
+ {isCreating ? "Create share" : "Share"}
193
235
  </h2>
194
236
  <p className="mt-0.5 truncate text-xs text-muted-foreground">
195
- {agent.metadata?.name || agent.metadata?.slug}
237
+ {/* Cross-org: qualify the agent so it's clear whose blueprint
238
+ this channel serves — the URL and billing are still yours. */}
239
+ {isCrossOrg
240
+ ? `${agent.metadata?.org}/${agent.metadata?.slug}`
241
+ : agent.metadata?.name || agent.metadata?.slug}
196
242
  </p>
197
243
  </div>
198
244
  <button
@@ -209,39 +255,19 @@ function ShareAgentDialogBody({
209
255
  </button>
210
256
  </div>
211
257
 
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>
258
+ {isCreating ? (
259
+ <CreateShareForm
260
+ agent={agent}
261
+ shareOrg={resolvedShareOrg}
262
+ onCreated={(persisted) => {
263
+ setCreated(persisted);
264
+ onSharingChanged?.();
265
+ }}
266
+ />
241
267
  ) : (
242
268
  <ShareAgentForm
243
269
  agent={agent}
244
- initialShare={share}
270
+ initialShare={activeShare}
245
271
  buildShareUrl={buildShareUrl}
246
272
  onSharingChanged={onSharingChanged}
247
273
  />
@@ -254,11 +280,13 @@ function ShareAgentDialogBody({
254
280
  onClick={onClose}
255
281
  className={cn(
256
282
  "rounded-md px-3 py-1.5 text-sm font-medium",
257
- "bg-primary text-primary-foreground hover:bg-primary-hover",
283
+ isCreating
284
+ ? "border border-border text-foreground hover:bg-accent-hover"
285
+ : "bg-primary text-primary-foreground hover:bg-primary-hover",
258
286
  "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
259
287
  )}
260
288
  >
261
- Done
289
+ {isCreating ? "Cancel" : "Done"}
262
290
  </button>
263
291
  </div>
264
292
  </div>
@@ -266,33 +294,231 @@ function ShareAgentDialogBody({
266
294
  }
267
295
 
268
296
  // ---------------------------------------------------------------------------
269
- // Formowns the share draft for the session
297
+ // Create step — the new share's identity and cost, before any channel exists
270
298
  // ---------------------------------------------------------------------------
271
299
 
272
300
  /**
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.
301
+ * Names the new share and creates it live (`enabled: true`, public
302
+ * audience the server's own defaults). Identity is set here because it
303
+ * is immutable afterward (decision 011 D2): the slug becomes the hosted
304
+ * URL `/chat/<org>/<slug>` in the sharing org's namespace.
305
+ *
306
+ * The slug auto-derives from the name until the user edits it (the
307
+ * {@link CreateOrganizationForm} pattern), validated by the same
308
+ * protovalidate rules the server enforces. An `(org, slug)` collision —
309
+ * the server's ALREADY_EXISTS — surfaces inline on the slug field with a
310
+ * pick-another-slug remedy instead of a generic failure toast.
277
311
  */
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 ?? "",
312
+ function CreateShareForm({
313
+ agent,
314
+ shareOrg,
315
+ onCreated,
316
+ }: {
317
+ readonly agent: Agent;
318
+ readonly shareOrg: string;
319
+ readonly onCreated: (share: AgentShare) => void;
320
+ }) {
321
+ const agentName = agent.metadata?.name || (agent.metadata?.slug ?? "");
322
+ const isCrossOrg = shareOrg !== (agent.metadata?.org ?? "");
323
+
324
+ const [name, setName] = useState(agentName);
325
+ const [slug, setSlug] = useState(agent.metadata?.slug ?? "");
326
+ // Once the user manually edits the slug field, stop auto-deriving.
327
+ const slugTouchedRef = useRef(false);
328
+ // A server-side (org, slug) collision pins to the slug field until the
329
+ // slug changes — the remedy is picking another slug.
330
+ const [collisionError, setCollisionError] = useState<string | null>(null);
331
+ const [submitError, setSubmitError] = useState<string | null>(null);
332
+
333
+ const { save, isPending } = useSaveAgentShare(agent, shareOrg);
334
+
335
+ const trimmedName = name.trim();
336
+ const violations =
337
+ slug.length > 0 || trimmedName.length > 0
338
+ ? validateMessage(
339
+ ApiResourceMetadataSchema,
340
+ createMessage(ApiResourceMetadataSchema, {
341
+ ...(slug.length > 0 && { slug }),
342
+ ...(trimmedName.length > 0 && { name: trimmedName }),
343
+ }),
344
+ )
345
+ : [];
346
+ const slugError =
347
+ collisionError ?? (slug.length > 0 ? getFieldError(violations, "slug") : null);
348
+ const nameError =
349
+ trimmedName.length > 0 ? getFieldError(violations, "name") : null;
350
+ const canSubmit =
351
+ trimmedName !== "" && slug.length > 0 && slugError === null &&
352
+ nameError === null && !isPending;
353
+
354
+ const handleNameChange = useCallback((value: string) => {
355
+ setName(value);
356
+ if (!slugTouchedRef.current) {
357
+ setSlug(generateSlug(value.trim()));
358
+ setCollisionError(null);
359
+ }
360
+ }, []);
361
+
362
+ const handleSlugChange = useCallback((value: string) => {
363
+ slugTouchedRef.current = true;
364
+ setSlug(value);
365
+ setCollisionError(null);
366
+ }, []);
367
+
368
+ const handleSubmit = useCallback(
369
+ async (e: FormEvent) => {
370
+ e.preventDefault();
371
+ if (!canSubmit) return;
372
+ setSubmitError(null);
373
+
374
+ try {
375
+ const persisted = await save(
376
+ // The server's create defaults, made explicit: live from the
377
+ // start (the user's one intent-click is "share it"), public
378
+ // audience, nothing else configured yet.
379
+ { ...draftFromShare(null), enabled: true },
380
+ null,
381
+ { name: trimmedName, slug },
382
+ );
383
+ if (persisted) {
384
+ toast.success("Share created — the link is live");
385
+ onCreated(persisted);
386
+ }
387
+ } catch (err) {
388
+ if (err instanceof StigmerError && err.code === "already-exists") {
389
+ setCollisionError(
390
+ `"${slug}" is already used by another share or resource in ${shareOrg} — pick a different slug`,
391
+ );
392
+ } else {
393
+ // e.g. the cross-org FAILED_PRECONDITION naming non-public
394
+ // dependencies (decision 013 D5) — show the server's words.
395
+ setSubmitError(getUserMessage(err));
396
+ }
397
+ }
288
398
  },
289
- environmentRefs: (spec?.environmentRefs ?? []).map((ref) => ({
290
- org: ref.org,
291
- slug: ref.slug,
292
- })),
293
- };
399
+ [canSubmit, save, trimmedName, slug, shareOrg, onCreated],
400
+ );
401
+
402
+ return (
403
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4 px-6 py-4">
404
+ <p className="text-xs text-muted-foreground">
405
+ A share is its own channel to this agent: a hosted chat link with
406
+ its own audience, embed origins, tool credentials, and visitor
407
+ messages.{" "}
408
+ {isCrossOrg && (
409
+ <>
410
+ This one lives in your organization{" "}
411
+ <span className="font-medium">{shareOrg}</span> while the agent
412
+ stays in <span className="font-medium">{agent.metadata?.org}</span>{" "}
413
+ — updates to the agent apply live.{" "}
414
+ </>
415
+ )}
416
+ Visitors chat on <span className="font-medium">{shareOrg}</span>&apos;s
417
+ credits.
418
+ </p>
419
+
420
+ {/* ---- Name ---- */}
421
+ <div className="space-y-1">
422
+ <label
423
+ htmlFor="stgm-new-share-name"
424
+ className="text-xs font-medium text-foreground"
425
+ >
426
+ Name
427
+ </label>
428
+ <input
429
+ id="stgm-new-share-name"
430
+ type="text"
431
+ value={name}
432
+ onChange={(e) => handleNameChange(e.target.value)}
433
+ disabled={isPending}
434
+ required
435
+ maxLength={63}
436
+ className={cn(
437
+ "w-full rounded-md border bg-input-bg px-2.5 py-1.5 text-xs text-foreground",
438
+ "placeholder:text-muted-foreground",
439
+ "focus:outline-none focus:ring-2 focus:ring-ring",
440
+ "disabled:pointer-events-none disabled:opacity-50",
441
+ nameError ? "border-destructive" : "border-border",
442
+ )}
443
+ />
444
+ {nameError ? (
445
+ <p className="text-[0.65rem] text-destructive" role="alert">
446
+ {nameError}
447
+ </p>
448
+ ) : (
449
+ <p className="text-[0.65rem] text-muted-foreground">
450
+ A display name for this share — e.g. the site or campaign it
451
+ serves.
452
+ </p>
453
+ )}
454
+ </div>
455
+
456
+ {/* ---- Slug ---- */}
457
+ <div className="space-y-1">
458
+ <label
459
+ htmlFor="stgm-new-share-slug"
460
+ className="text-xs font-medium text-foreground"
461
+ >
462
+ Slug
463
+ </label>
464
+ <input
465
+ id="stgm-new-share-slug"
466
+ type="text"
467
+ value={slug}
468
+ onChange={(e) => handleSlugChange(e.target.value)}
469
+ disabled={isPending}
470
+ required
471
+ className={cn(
472
+ "w-full rounded-md border bg-input-bg px-2.5 py-1.5 font-mono text-xs text-foreground",
473
+ "placeholder:text-muted-foreground",
474
+ "focus:outline-none focus:ring-2 focus:ring-ring",
475
+ "disabled:pointer-events-none disabled:opacity-50",
476
+ slugError ? "border-destructive" : "border-border",
477
+ )}
478
+ />
479
+ {slugError ? (
480
+ <p className="text-[0.65rem] text-destructive" role="alert">
481
+ {slugError}
482
+ </p>
483
+ ) : (
484
+ <p className="text-[0.65rem] text-muted-foreground">
485
+ Becomes the share&apos;s address:{" "}
486
+ <code className="font-mono">
487
+ /chat/{shareOrg}/{slug || "…"}
488
+ </code>
489
+ . Can&apos;t be changed later.
490
+ </p>
491
+ )}
492
+ </div>
493
+
494
+ {submitError && (
495
+ <p className="text-xs text-destructive" role="alert">
496
+ {submitError}
497
+ </p>
498
+ )}
499
+
500
+ <div className="flex items-center justify-end">
501
+ <button
502
+ type="submit"
503
+ disabled={!canSubmit}
504
+ className={cn(
505
+ "rounded-md px-3 py-1.5 text-sm font-medium",
506
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
507
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
508
+ "disabled:pointer-events-none disabled:opacity-50",
509
+ )}
510
+ >
511
+ {isPending ? "Creating…" : "Create share"}
512
+ </button>
513
+ </div>
514
+ </form>
515
+ );
294
516
  }
295
517
 
518
+ // ---------------------------------------------------------------------------
519
+ // Form — owns the share draft for the session
520
+ // ---------------------------------------------------------------------------
521
+
296
522
  function ShareAgentForm({
297
523
  agent,
298
524
  initialShare,
@@ -300,16 +526,15 @@ function ShareAgentForm({
300
526
  onSharingChanged,
301
527
  }: {
302
528
  readonly agent: Agent;
303
- readonly initialShare: AgentShare | null;
529
+ readonly initialShare: AgentShare;
304
530
  readonly buildShareUrl?: (org: string, slug: string) => string;
305
531
  readonly onSharingChanged?: () => void;
306
532
  }) {
307
533
  const agentName = agent.metadata?.name || (agent.metadata?.slug ?? "");
308
534
 
309
535
  // 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);
536
+ // token, and the share id for rotation all derive from it.
537
+ const [share, setShare] = useState<AgentShare>(initialShare);
313
538
  const [draft, setDraft] = useState<AgentShareDraft>(() =>
314
539
  draftFromShare(initialShare),
315
540
  );
@@ -317,15 +542,18 @@ function ShareAgentForm({
317
542
 
318
543
  const { save, isPending } = useSaveAgentShare(agent);
319
544
  const { rotateShareLink, isPending: isRotating } = useRotateShareLink(
320
- share?.metadata?.id ?? null,
545
+ share.metadata?.id ?? null,
321
546
  );
322
547
 
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 ?? "";
548
+ // The share's own org/slug are the hosted URL for a cross-org share
549
+ // (decision 013) that org is the sharing org's, not the agent's.
550
+ const org = share.metadata?.org ?? "";
551
+ const slug = share.metadata?.slug ?? "";
552
+ const linkToken = share.status?.shareLinkToken ?? "";
553
+ // Cross-org shares are public-audience only (decision 013 D3) — the
554
+ // audience selector disappears rather than offering a choice the
555
+ // server would refuse.
556
+ const isCrossOrg = org !== (agent.metadata?.org ?? "");
329
557
 
330
558
  // Single commit path: apply the complete draft, adopt the server's
331
559
  // returned share as the new baseline, notify the host.
@@ -431,11 +659,13 @@ function ShareAgentForm({
431
659
  aria-labelledby="share-enabled-label"
432
660
  />
433
661
  </div>
434
- <AudienceSelector
435
- audience={draft.audience}
436
- onChange={handleAudienceChange}
437
- disabled={isPending}
438
- />
662
+ {!isCrossOrg && (
663
+ <AudienceSelector
664
+ audience={draft.audience}
665
+ onChange={handleAudienceChange}
666
+ disabled={isPending}
667
+ />
668
+ )}
439
669
  <ToolReadinessHint agent={agent} draft={draft} />
440
670
  </div>
441
671