@stigmer/react 3.1.12 → 3.1.13

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 (40) hide show
  1. package/agent/AgentDetailView.d.ts +11 -1
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +17 -4
  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 +1 -1
  8. package/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/sharing/ShareAgentDialog.d.ts +11 -1
  11. package/sharing/ShareAgentDialog.d.ts.map +1 -1
  12. package/sharing/ShareAgentDialog.js +20 -11
  13. package/sharing/ShareAgentDialog.js.map +1 -1
  14. package/sharing/index.d.ts +2 -0
  15. package/sharing/index.d.ts.map +1 -1
  16. package/sharing/index.js +1 -0
  17. package/sharing/index.js.map +1 -1
  18. package/sharing/useAgentShare.d.ts +8 -2
  19. package/sharing/useAgentShare.d.ts.map +1 -1
  20. package/sharing/useAgentShare.js +23 -13
  21. package/sharing/useAgentShare.js.map +1 -1
  22. package/sharing/useCreateExternalShareLink.d.ts +90 -0
  23. package/sharing/useCreateExternalShareLink.d.ts.map +1 -0
  24. package/sharing/useCreateExternalShareLink.js +65 -0
  25. package/sharing/useCreateExternalShareLink.js.map +1 -0
  26. package/sharing/useSaveAgentShare.d.ts +7 -1
  27. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  28. package/sharing/useSaveAgentShare.js +12 -5
  29. package/sharing/useSaveAgentShare.js.map +1 -1
  30. package/src/agent/AgentDetailView.tsx +28 -2
  31. package/src/index.ts +3 -0
  32. package/src/sharing/ShareAgentDialog.tsx +43 -11
  33. package/src/sharing/__tests__/ShareAgentDialog.test.tsx +48 -0
  34. package/src/sharing/__tests__/useAgentShare.test.tsx +61 -0
  35. package/src/sharing/__tests__/useCreateExternalShareLink.test.tsx +194 -0
  36. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +48 -0
  37. package/src/sharing/index.ts +5 -0
  38. package/src/sharing/useAgentShare.ts +26 -12
  39. package/src/sharing/useCreateExternalShareLink.tsx +141 -0
  40. package/src/sharing/useSaveAgentShare.ts +12 -4
@@ -65,6 +65,16 @@ export interface ShareAgentDialogProps {
65
65
  * the agent data hook's `refetch`.
66
66
  */
67
67
  readonly onSharingChanged?: () => void;
68
+ /**
69
+ * The org that owns (and pays for) the share. Defaults to the agent's
70
+ * own org — the owner's channel. Pass the viewer's org to manage a
71
+ * **cross-org share** of another org's marketplace-public agent
72
+ * (decision 013): the share, its billing, its credentials, and its
73
+ * hosted URL all belong to this org, while the agent stays live in its
74
+ * own. Cross-org shares are public-audience only, so the audience
75
+ * selector is hidden in that mode.
76
+ */
77
+ readonly shareOrg?: string;
68
78
  /**
69
79
  * When `false`, renders as an in-flow open dialog instead of a
70
80
  * top-layer modal — no `showModal()`, no backdrop, no focus trap.
@@ -106,6 +116,7 @@ export function ShareAgentDialog({
106
116
  agent,
107
117
  buildShareUrl,
108
118
  onSharingChanged,
119
+ shareOrg,
109
120
  modal = true,
110
121
  }: ShareAgentDialogProps) {
111
122
  const dialogRef = useRef<HTMLDialogElement>(null);
@@ -150,6 +161,7 @@ export function ShareAgentDialog({
150
161
  agent={agent}
151
162
  buildShareUrl={buildShareUrl}
152
163
  onSharingChanged={onSharingChanged}
164
+ shareOrg={shareOrg}
153
165
  onClose={handleClose}
154
166
  />
155
167
  )}
@@ -171,14 +183,18 @@ function ShareAgentDialogBody({
171
183
  agent,
172
184
  buildShareUrl,
173
185
  onSharingChanged,
186
+ shareOrg,
174
187
  onClose,
175
188
  }: {
176
189
  readonly agent: Agent;
177
190
  readonly buildShareUrl?: (org: string, slug: string) => string;
178
191
  readonly onSharingChanged?: () => void;
192
+ readonly shareOrg?: string;
179
193
  readonly onClose: () => void;
180
194
  }) {
181
- const { share, isLoading, error, refetch } = useAgentShare(agent);
195
+ const { share, isLoading, error, refetch } = useAgentShare(agent, shareOrg);
196
+ const isCrossOrg =
197
+ !!shareOrg && shareOrg !== (agent.metadata?.org ?? "");
182
198
 
183
199
  return (
184
200
  <div className="flex flex-col">
@@ -192,7 +208,11 @@ function ShareAgentDialogBody({
192
208
  Share
193
209
  </h2>
194
210
  <p className="mt-0.5 truncate text-xs text-muted-foreground">
195
- {agent.metadata?.name || agent.metadata?.slug}
211
+ {/* Cross-org: qualify the agent so it's clear whose blueprint
212
+ this channel serves — the URL and billing are still yours. */}
213
+ {isCrossOrg
214
+ ? `${agent.metadata?.org}/${agent.metadata?.slug}`
215
+ : agent.metadata?.name || agent.metadata?.slug}
196
216
  </p>
197
217
  </div>
198
218
  <button
@@ -244,6 +264,7 @@ function ShareAgentDialogBody({
244
264
  initialShare={share}
245
265
  buildShareUrl={buildShareUrl}
246
266
  onSharingChanged={onSharingChanged}
267
+ shareOrg={shareOrg}
247
268
  />
248
269
  )}
249
270
 
@@ -298,11 +319,13 @@ function ShareAgentForm({
298
319
  initialShare,
299
320
  buildShareUrl,
300
321
  onSharingChanged,
322
+ shareOrg,
301
323
  }: {
302
324
  readonly agent: Agent;
303
325
  readonly initialShare: AgentShare | null;
304
326
  readonly buildShareUrl?: (org: string, slug: string) => string;
305
327
  readonly onSharingChanged?: () => void;
328
+ readonly shareOrg?: string;
306
329
  }) {
307
330
  const agentName = agent.metadata?.name || (agent.metadata?.slug ?? "");
308
331
 
@@ -315,17 +338,24 @@ function ShareAgentForm({
315
338
  );
316
339
  const [activeTab, setActiveTab] = useState("link");
317
340
 
318
- const { save, isPending } = useSaveAgentShare(agent);
341
+ const { save, isPending } = useSaveAgentShare(agent, shareOrg);
319
342
  const { rotateShareLink, isPending: isRotating } = useRotateShareLink(
320
343
  share?.metadata?.id ?? null,
321
344
  );
322
345
 
323
346
  // 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 ?? "");
347
+ // the sharing org + the agent's slug stand in — exactly the identity
348
+ // the server will assign on create (D2: share slug defaults to the
349
+ // agent's; the org is the channel owner's, which for a cross-org share
350
+ // is the viewer's org, not the agent's — decision 013).
351
+ const org =
352
+ share?.metadata?.org || shareOrg || (agent.metadata?.org ?? "");
327
353
  const slug = share?.metadata?.slug || (agent.metadata?.slug ?? "");
328
354
  const linkToken = share?.status?.shareLinkToken ?? "";
355
+ // Cross-org shares are public-audience only (decision 013 D3) — the
356
+ // audience selector disappears rather than offering a choice the
357
+ // server would refuse.
358
+ const isCrossOrg = org !== (agent.metadata?.org ?? "");
329
359
 
330
360
  // Single commit path: apply the complete draft, adopt the server's
331
361
  // returned share as the new baseline, notify the host.
@@ -431,11 +461,13 @@ function ShareAgentForm({
431
461
  aria-labelledby="share-enabled-label"
432
462
  />
433
463
  </div>
434
- <AudienceSelector
435
- audience={draft.audience}
436
- onChange={handleAudienceChange}
437
- disabled={isPending}
438
- />
464
+ {!isCrossOrg && (
465
+ <AudienceSelector
466
+ audience={draft.audience}
467
+ onChange={handleAudienceChange}
468
+ disabled={isPending}
469
+ />
470
+ )}
439
471
  <ToolReadinessHint agent={agent} draft={draft} />
440
472
  </div>
441
473
 
@@ -464,6 +464,54 @@ describe("ShareAgentDialog", () => {
464
464
  });
465
465
  });
466
466
 
467
+ describe("cross-org mode (shareOrg — decision 013)", () => {
468
+ it("qualifies the agent in the header and hides the audience selector", async () => {
469
+ // No share yet in the consumer org: the owner's share exists but
470
+ // belongs to acme, so the consumer's dialog starts never-shared.
471
+ const client = createMockStigmer({
472
+ getByAgent: withShare(makeShare({ enabled: true })),
473
+ });
474
+ await renderOpenDialog(client, { shareOrg: "consumer-org" });
475
+
476
+ // The header names whose blueprint this channel serves.
477
+ expect(screen.getByText("acme/support-agent")).toBeTruthy();
478
+ // Cross-org shares are public-audience only — no choice to offer.
479
+ expect(screen.queryByRole("radiogroup", { hidden: true })).toBeNull();
480
+ });
481
+
482
+ it("builds the link, billing line, and create identity from the sharing org", async () => {
483
+ const apply = vi.fn().mockResolvedValue({});
484
+ const client = createMockStigmer({ apply });
485
+ await renderOpenDialog(client, { shareOrg: "consumer-org" });
486
+
487
+ // The hosted URL lives in the sharing org's namespace even before
488
+ // the first save.
489
+ expect(
490
+ screen.getByText("https://app.example.com/chat/consumer-org/support-agent"),
491
+ ).toBeTruthy();
492
+ // Who-pays names the sharing org, not the agent's.
493
+ expect(screen.getByText("consumer-org")).toBeTruthy();
494
+
495
+ fireEvent.click(screen.getByRole("switch", { hidden: true }));
496
+
497
+ await waitFor(() => expect(apply).toHaveBeenCalledTimes(1));
498
+ const input = apply.mock.calls[0][0] as AgentShareInput;
499
+ expect(input.org).toBe("consumer-org");
500
+ expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
501
+ expect(input.audience).toBe(AgentShareAudience.public);
502
+ });
503
+
504
+ it("same-org dialogs are unchanged when shareOrg equals the agent's org", async () => {
505
+ const client = createMockStigmer({
506
+ getByAgent: withShare(makeShare({ enabled: true })),
507
+ });
508
+ await renderOpenDialog(client, { shareOrg: "acme" });
509
+
510
+ expect(screen.getByText("Support Agent")).toBeTruthy();
511
+ expect(screen.getByRole("radiogroup", { hidden: true })).toBeTruthy();
512
+ });
513
+ });
514
+
467
515
  it("enabling applies the complete spec and notifies the host", async () => {
468
516
  const apply = vi.fn().mockResolvedValue({});
469
517
  const onSharingChanged = vi.fn();
@@ -132,6 +132,67 @@ describe("useAgentShare", () => {
132
132
  expect(getByAgent).not.toHaveBeenCalled();
133
133
  });
134
134
 
135
+ describe("cross-org share resolution (shareOrg — decision 013)", () => {
136
+ it("scopes the canonical pick to the sharing org's channel", async () => {
137
+ // The same agent shared in two orgs: the owner's share AND another
138
+ // org's external share. Each org's dialog must resolve its own row.
139
+ const ownerShare = makeShare("support-agent");
140
+ const externalShare = {
141
+ metadata: { id: "ash_ext", org: "consumer-org", slug: "support-agent" },
142
+ spec: { enabled: true },
143
+ };
144
+ const getByAgent = vi
145
+ .fn()
146
+ .mockResolvedValue({ totalCount: 2, items: [ownerShare, externalShare] });
147
+ const client = createMockStigmer({ getByAgent });
148
+
149
+ const { result } = renderHook(
150
+ () => useAgentShare(AGENT, "consumer-org"),
151
+ { wrapper: wrapper(client) },
152
+ );
153
+
154
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
155
+ expect(result.current.share).toBe(externalShare);
156
+ });
157
+
158
+ it("resolves null when only OTHER orgs' shares exist — never edits a foreign channel", async () => {
159
+ const ownerShare = makeShare("support-agent");
160
+ const getByAgent = vi
161
+ .fn()
162
+ .mockResolvedValue({ totalCount: 1, items: [ownerShare] });
163
+ const client = createMockStigmer({ getByAgent });
164
+
165
+ const { result } = renderHook(
166
+ () => useAgentShare(AGENT, "consumer-org"),
167
+ { wrapper: wrapper(client) },
168
+ );
169
+
170
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
171
+ // Without the org filter this would fall back to the owner's share
172
+ // and a save would overwrite the wrong org's configuration.
173
+ expect(result.current.share).toBeNull();
174
+ });
175
+
176
+ it("defaults shareOrg to the agent's own org (Phase A behavior unchanged)", async () => {
177
+ const ownerShare = makeShare("support-agent");
178
+ const externalShare = {
179
+ metadata: { id: "ash_ext", org: "consumer-org", slug: "support-agent" },
180
+ spec: { enabled: true },
181
+ };
182
+ const getByAgent = vi
183
+ .fn()
184
+ .mockResolvedValue({ totalCount: 2, items: [externalShare, ownerShare] });
185
+ const client = createMockStigmer({ getByAgent });
186
+
187
+ const { result } = renderHook(() => useAgentShare(AGENT), {
188
+ wrapper: wrapper(client),
189
+ });
190
+
191
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
192
+ expect(result.current.share).toBe(ownerShare);
193
+ });
194
+ });
195
+
135
196
  it("exposes fetch failures as errors", async () => {
136
197
  const getByAgent = vi
137
198
  .fn()
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
2
+ import { renderHook, waitFor, cleanup } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
5
+ import { StigmerContext } from "../../context";
6
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
7
+ import { useCreateExternalShareLink } from "../useCreateExternalShareLink";
8
+
9
+ // happy-dom does not implement the native dialog show/close methods.
10
+ beforeAll(() => {
11
+ HTMLDialogElement.prototype.showModal = function showModal() {
12
+ this.open = true;
13
+ };
14
+ HTMLDialogElement.prototype.close = function close() {
15
+ this.open = false;
16
+ };
17
+ });
18
+
19
+ afterEach(cleanup);
20
+
21
+ function createMockStigmer(overrides: {
22
+ isAuthorized?: boolean;
23
+ checkMyPermission?: (...args: unknown[]) => Promise<unknown>;
24
+ } = {}) {
25
+ return {
26
+ iamPolicy: {
27
+ checkMyPermission:
28
+ overrides.checkMyPermission ??
29
+ vi.fn().mockResolvedValue({
30
+ isAuthorized: overrides.isAuthorized ?? true,
31
+ }),
32
+ },
33
+ agentShare: {
34
+ getByAgent: vi.fn().mockResolvedValue({ totalCount: 0, items: [] }),
35
+ apply: vi.fn().mockResolvedValue({}),
36
+ rotateShareLink: vi.fn().mockResolvedValue({}),
37
+ },
38
+ billing: { getOrCreateBillingAccount: vi.fn().mockResolvedValue(null) },
39
+ } as never;
40
+ }
41
+
42
+ function wrapper(client: unknown) {
43
+ return function Wrapper({ children }: { children: ReactNode }) {
44
+ return (
45
+ <FetchCacheContext.Provider value={null}>
46
+ <StigmerContext.Provider value={client as never}>
47
+ {children}
48
+ </StigmerContext.Provider>
49
+ </FetchCacheContext.Provider>
50
+ );
51
+ };
52
+ }
53
+
54
+ function makeAgent(visibility: ApiResourceVisibility) {
55
+ return {
56
+ metadata: {
57
+ id: "agt_1",
58
+ org: "provider-org",
59
+ slug: "public-helper",
60
+ name: "Public Helper",
61
+ visibility,
62
+ },
63
+ spec: {},
64
+ } as never;
65
+ }
66
+
67
+ const PUBLIC_AGENT = makeAgent(ApiResourceVisibility.visibility_public);
68
+ const PRIVATE_AGENT = makeAgent(ApiResourceVisibility.visibility_private);
69
+
70
+ describe("useCreateExternalShareLink", () => {
71
+ it("offers the action on another org's public agent when the viewer holds the org bar", async () => {
72
+ const { result } = renderHook(
73
+ () =>
74
+ useCreateExternalShareLink({
75
+ agent: PUBLIC_AGENT,
76
+ viewerOrg: "consumer-org",
77
+ }),
78
+ { wrapper: wrapper(createMockStigmer({ isAuthorized: true })) },
79
+ );
80
+
81
+ await waitFor(() => expect(result.current.action).not.toBeNull());
82
+ expect(result.current.action?.id).toBe("create-share-link");
83
+ expect(result.current.action?.label).toBe("Create share link");
84
+ expect(result.current.action?.group).toBe("sharing");
85
+ });
86
+
87
+ it("checks can_create_agent_share on the VIEWER's org (id = slug)", async () => {
88
+ const client = createMockStigmer({ isAuthorized: true });
89
+ renderHook(
90
+ () =>
91
+ useCreateExternalShareLink({
92
+ agent: PUBLIC_AGENT,
93
+ viewerOrg: "consumer-org",
94
+ }),
95
+ { wrapper: wrapper(client) },
96
+ );
97
+
98
+ const check = (
99
+ client as { iamPolicy: { checkMyPermission: ReturnType<typeof vi.fn> } }
100
+ ).iamPolicy.checkMyPermission;
101
+ await waitFor(() => expect(check).toHaveBeenCalled());
102
+ const input = check.mock.calls[0][0] as {
103
+ resource?: { kind: string; id: string };
104
+ relation: string;
105
+ };
106
+ expect(input.relation).toBe("can_create_agent_share");
107
+ expect(input.resource?.kind).toBe("organization");
108
+ expect(input.resource?.id).toBe("consumer-org");
109
+ });
110
+
111
+ it("stays null on the viewer's OWN agents — the same-org Share entry covers those", () => {
112
+ const checkMyPermission = vi.fn();
113
+ const { result } = renderHook(
114
+ () =>
115
+ useCreateExternalShareLink({
116
+ agent: PUBLIC_AGENT,
117
+ viewerOrg: "provider-org",
118
+ }),
119
+ { wrapper: wrapper(createMockStigmer({ checkMyPermission })) },
120
+ );
121
+
122
+ expect(result.current.action).toBeNull();
123
+ expect(result.current.dialog).toBeNull();
124
+ // Not applicable structurally: no permission round-trip is spent.
125
+ expect(checkMyPermission).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it("stays null on a non-public agent — visibility is the origin org's consent", () => {
129
+ const checkMyPermission = vi.fn();
130
+ const { result } = renderHook(
131
+ () =>
132
+ useCreateExternalShareLink({
133
+ agent: PRIVATE_AGENT,
134
+ viewerOrg: "consumer-org",
135
+ }),
136
+ { wrapper: wrapper(createMockStigmer({ checkMyPermission })) },
137
+ );
138
+
139
+ expect(result.current.action).toBeNull();
140
+ expect(checkMyPermission).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("stays null while the agent is loading or the viewer org is unknown", () => {
144
+ const client = createMockStigmer();
145
+ const { result: loading } = renderHook(
146
+ () => useCreateExternalShareLink({ agent: null, viewerOrg: "consumer-org" }),
147
+ { wrapper: wrapper(client) },
148
+ );
149
+ const { result: orgless } = renderHook(
150
+ () => useCreateExternalShareLink({ agent: PUBLIC_AGENT, viewerOrg: "" }),
151
+ { wrapper: wrapper(client) },
152
+ );
153
+
154
+ expect(loading.current.action).toBeNull();
155
+ expect(orgless.current.action).toBeNull();
156
+ });
157
+
158
+ it("hides the action when the viewer lacks the org-side permission", async () => {
159
+ const client = createMockStigmer({ isAuthorized: false });
160
+ const { result } = renderHook(
161
+ () =>
162
+ useCreateExternalShareLink({
163
+ agent: PUBLIC_AGENT,
164
+ viewerOrg: "consumer-org",
165
+ }),
166
+ { wrapper: wrapper(client) },
167
+ );
168
+
169
+ await waitFor(() =>
170
+ expect(
171
+ (client as { iamPolicy: { checkMyPermission: ReturnType<typeof vi.fn> } })
172
+ .iamPolicy.checkMyPermission,
173
+ ).toHaveBeenCalled(),
174
+ );
175
+ await waitFor(() => expect(result.current.action).toBeNull());
176
+ });
177
+
178
+ it("opens the cross-org dialog via the action", async () => {
179
+ const { result } = renderHook(
180
+ () =>
181
+ useCreateExternalShareLink({
182
+ agent: PUBLIC_AGENT,
183
+ viewerOrg: "consumer-org",
184
+ }),
185
+ { wrapper: wrapper(createMockStigmer({ isAuthorized: true })) },
186
+ );
187
+
188
+ await waitFor(() => expect(result.current.action).not.toBeNull());
189
+ expect(result.current.isOpen).toBe(false);
190
+
191
+ result.current.action?.onAction();
192
+ await waitFor(() => expect(result.current.isOpen).toBe(true));
193
+ });
194
+ });
@@ -163,6 +163,54 @@ describe("useSaveAgentShare", () => {
163
163
  expect(second.audience).toBe(AgentShareAudience.org);
164
164
  });
165
165
 
166
+ describe("cross-org create identity (shareOrg — decision 013)", () => {
167
+ it("a first save lands the share in the sharing org, agent_ref stays the agent's", async () => {
168
+ const apply = vi.fn().mockResolvedValue({});
169
+ const client = createMockStigmer({ apply });
170
+
171
+ const { result } = renderHook(
172
+ () => useSaveAgentShare(AGENT, "consumer-org"),
173
+ { wrapper: wrapper(client) },
174
+ );
175
+
176
+ await act(() => result.current.save(FULL_DRAFT, null));
177
+
178
+ const input = apply.mock.calls[0][0] as AgentShareInput;
179
+ // The share is the sharing org's resource (its URL, billing, and
180
+ // credentials), while agent_ref keeps pointing at the provider's
181
+ // blueprint — the whole point of a cross-org share.
182
+ expect(input.org).toBe("consumer-org");
183
+ expect(input.slug).toBe("support-agent");
184
+ expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
185
+ });
186
+
187
+ it("editing an existing cross-org share keeps ITS identity, not the hook argument's", async () => {
188
+ const apply = vi.fn().mockResolvedValue({});
189
+ const client = createMockStigmer({ apply });
190
+ const externalShare = {
191
+ metadata: {
192
+ id: "ash_ext",
193
+ org: "consumer-org",
194
+ slug: "renamed-channel",
195
+ name: "Renamed Channel",
196
+ },
197
+ spec: { enabled: true },
198
+ } as AgentShare;
199
+
200
+ const { result } = renderHook(
201
+ () => useSaveAgentShare(AGENT, "consumer-org"),
202
+ { wrapper: wrapper(client) },
203
+ );
204
+
205
+ await act(() => result.current.save(FULL_DRAFT, externalShare));
206
+
207
+ const input = apply.mock.calls[0][0] as AgentShareInput;
208
+ expect(input.org).toBe("consumer-org");
209
+ expect(input.slug).toBe("renamed-channel");
210
+ expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
211
+ });
212
+ });
213
+
166
214
  it("is a stable no-op when the agent is null", async () => {
167
215
  const apply = vi.fn();
168
216
  const client = createMockStigmer({ apply });
@@ -25,6 +25,11 @@ export type {
25
25
  UseShareAgentArgs,
26
26
  UseShareAgentReturn,
27
27
  } from "./useShareAgent.js";
28
+ export { useCreateExternalShareLink } from "./useCreateExternalShareLink.js";
29
+ export type {
30
+ UseCreateExternalShareLinkArgs,
31
+ UseCreateExternalShareLinkReturn,
32
+ } from "./useCreateExternalShareLink.js";
28
33
  export { useShareToolReadiness } from "./useShareToolReadiness.js";
29
34
  export type { ShareToolReadiness } from "./useShareToolReadiness.js";
30
35
  // Origin validation moved to @stigmer/sdk (framework-free, shared with the
@@ -26,20 +26,24 @@ export interface UseAgentShareReturn {
26
26
  }
27
27
 
28
28
  /**
29
- * The canonical share among an agent's shares: the one whose slug equals
30
- * the agent's slug (the server's default when a share is created without
31
- * an explicit slug), falling back to the first entry. The data model
32
- * allows N shares per agent (decision 011 D3); the console manages the
33
- * canonical one in Phase A, so extra shares created via manifests never
34
- * confuse the dialog.
29
+ * The canonical share among an agent's shares **within one sharing org**:
30
+ * the one whose slug equals the agent's slug (the server's default when a
31
+ * share is created without an explicit slug), falling back to the first
32
+ * entry in that org. The data model allows N shares per agent across N
33
+ * orgs (decision 011 D3 + decision 013), so the org filter is what keeps
34
+ * each org's dialog on its own channel — without it, an owner who can
35
+ * also see another org's share of the same agent would edit the wrong
36
+ * one. Extra shares created via manifests never confuse the dialog.
35
37
  */
36
38
  function pickCanonicalShare(
37
39
  shares: readonly AgentShare[],
40
+ shareOrg: string,
38
41
  agentSlug: string,
39
42
  ): AgentShare | null {
43
+ const inOrg = shares.filter((share) => share.metadata?.org === shareOrg);
40
44
  return (
41
- shares.find((share) => share.metadata?.slug === agentSlug) ??
42
- shares[0] ??
45
+ inOrg.find((share) => share.metadata?.slug === agentSlug) ??
46
+ inOrg[0] ??
43
47
  null
44
48
  );
45
49
  }
@@ -54,9 +58,15 @@ function pickCanonicalShare(
54
58
  * tell whether it is shared. This hook is how owner-side surfaces (the
55
59
  * Share dialog) resolve that state.
56
60
  *
61
+ * `shareOrg` scopes resolution to one sharing org's channel and defaults
62
+ * to the agent's own org (the owner's share). Pass the viewer's org to
63
+ * manage a **cross-org share** — the viewer's own channel of another
64
+ * org's marketplace-public agent (decision 013).
65
+ *
57
66
  * Pass `null` for `agent` to skip fetching (stable no-op) — useful
58
67
  * while the agent is still loading. A resolved `null` share means the
59
- * agent has never been shared; the first save creates the share.
68
+ * agent has never been shared in `shareOrg`; the first save creates the
69
+ * share.
60
70
  *
61
71
  * @example
62
72
  * ```tsx
@@ -66,24 +76,28 @@ function pickCanonicalShare(
66
76
  * const enabled = share?.spec?.enabled ?? false;
67
77
  * ```
68
78
  */
69
- export function useAgentShare(agent: Agent | null): UseAgentShareReturn {
79
+ export function useAgentShare(
80
+ agent: Agent | null,
81
+ shareOrg?: string,
82
+ ): UseAgentShareReturn {
70
83
  const stigmer = useStigmer();
71
84
 
72
85
  const agentId = agent?.metadata?.id ?? "";
73
86
  const agentSlug = agent?.metadata?.slug ?? "";
87
+ const resolvedShareOrg = shareOrg || (agent?.metadata?.org ?? "");
74
88
 
75
89
  const fetchFn = agentId
76
90
  ? async () => {
77
91
  const result = await stigmer.agentShare.getByAgent(
78
92
  create(GetAgentSharesByAgentRequestSchema, { agentId }),
79
93
  );
80
- return pickCanonicalShare(result.items, agentSlug);
94
+ return pickCanonicalShare(result.items, resolvedShareOrg, agentSlug);
81
95
  }
82
96
  : null;
83
97
 
84
98
  const { data: share, isLoading, isRefetching, error, refetch } = useFetch(
85
99
  fetchFn,
86
- [agentId, agentSlug, stigmer],
100
+ [agentId, agentSlug, resolvedShareOrg, stigmer],
87
101
  null as AgentShare | null,
88
102
  );
89
103