@agent-native/core 0.211.0 → 0.212.0-nightly-20261021010049

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 (91) hide show
  1. package/README.md +1 -1
  2. package/dist/app-config/analytics.d.ts +1 -0
  3. package/dist/app-config/analytics.js +7 -0
  4. package/dist/app-config/schema.d.ts +1 -0
  5. package/dist/chat-threads/store.d.ts +28 -2
  6. package/dist/chat-threads/store.js +75 -13
  7. package/dist/cli/template-baseline.js +18 -5
  8. package/dist/cli/template-sync.js +100 -19
  9. package/dist/client/agent-chat/index.d.ts +2 -1
  10. package/dist/client/agent-chat/index.js +2 -1
  11. package/dist/client/chat/agentkit-protocol.js +21 -1
  12. package/dist/client/chat/run-outcome.d.ts +1 -0
  13. package/dist/client/chat/runtime.js +6 -0
  14. package/dist/client/sharing/index.d.ts +2 -1
  15. package/dist/client/sharing/index.js +2 -1
  16. package/dist/client/sharing/share-controller-helpers.d.ts +14 -1
  17. package/dist/client/sharing/share-controller-helpers.js +46 -1
  18. package/dist/client/sharing/useShareButtonController.d.ts +12 -0
  19. package/dist/client/sharing/useShareButtonController.js +76 -57
  20. package/dist/client/sharing/useShareDialogController.d.ts +3 -0
  21. package/dist/client/sharing/useShareDialogController.js +77 -49
  22. package/dist/client/sharing/useShareVisibilityTransition.d.ts +68 -0
  23. package/dist/client/sharing/useShareVisibilityTransition.js +224 -0
  24. package/dist/client/use-chat-thread-list.d.ts +40 -0
  25. package/dist/client/use-chat-thread-list.js +210 -0
  26. package/dist/client/use-chat-threads.d.ts +67 -0
  27. package/dist/client/use-chat-threads.js +141 -23
  28. package/dist/client/use-db-sync.d.ts +1 -0
  29. package/dist/client/use-db-sync.js +13 -1
  30. package/dist/collab/__fixtures__/live-copy-tasks.d.ts +1 -0
  31. package/dist/collab/__fixtures__/live-copy-tasks.js +92 -0
  32. package/dist/collab/awareness.d.ts +2 -2
  33. package/dist/collab/bounded-task-bundle.d.ts +17 -0
  34. package/dist/collab/bounded-task-bundle.js +39 -0
  35. package/dist/collab/bounded-task-source.d.ts +14 -0
  36. package/dist/collab/bounded-task-source.js +8 -0
  37. package/dist/collab/bounded-tasks.d.ts +67 -0
  38. package/dist/collab/bounded-tasks.js +439 -0
  39. package/dist/collab/client.js +2 -1
  40. package/dist/collab/index.d.ts +5 -2
  41. package/dist/collab/index.js +5 -2
  42. package/dist/collab/live-copy-tasks.d.ts +80 -0
  43. package/dist/collab/live-copy-tasks.js +330 -0
  44. package/dist/collab/live-copy.d.ts +188 -0
  45. package/dist/collab/live-copy.js +415 -0
  46. package/dist/collab/routes.d.ts +5 -0
  47. package/dist/collab/routes.js +24 -8
  48. package/dist/collab/storage.d.ts +53 -1
  49. package/dist/collab/storage.js +93 -5
  50. package/dist/collab/struct-routes.d.ts +1 -1
  51. package/dist/collab/ydoc-manager.d.ts +92 -3
  52. package/dist/collab/ydoc-manager.js +447 -38
  53. package/dist/db/client.d.ts +6 -0
  54. package/dist/db/client.js +25 -5
  55. package/dist/deploy/build.js +2 -0
  56. package/dist/extensions/actions.js +1 -0
  57. package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
  58. package/dist/mcp/agent-connected.js +5 -1
  59. package/dist/mcp/build-server.js +39 -10
  60. package/dist/notifications/routes.d.ts +1 -1
  61. package/dist/observability/routes.d.ts +1 -1
  62. package/dist/org/index.d.ts +2 -2
  63. package/dist/progress/routes.d.ts +1 -1
  64. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  65. package/dist/provider-api/actions/provider-api.d.ts +10 -10
  66. package/dist/server/agent-chat-plugin.js +80 -17
  67. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  68. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  69. package/dist/server/analytics.d.ts +1 -0
  70. package/dist/server/analytics.js +18 -1
  71. package/dist/server/collab-plugin.d.ts +10 -1
  72. package/dist/server/collab-plugin.js +89 -20
  73. package/dist/server/index.d.ts +1 -1
  74. package/dist/server/realtime-token.d.ts +1 -1
  75. package/dist/shared/optional-node-builtins.d.ts +1 -0
  76. package/dist/shared/optional-node-builtins.js +3 -0
  77. package/dist/sharing/access.d.ts +1 -0
  78. package/dist/sharing/access.js +113 -95
  79. package/dist/tracking/providers.d.ts +1 -0
  80. package/dist/tracking/providers.js +2 -1
  81. package/dist/triggers/actions/manage-automation.d.ts +2 -2
  82. package/dist/vite/bounded-task-plugin.d.ts +22 -0
  83. package/dist/vite/bounded-task-plugin.js +44 -0
  84. package/dist/vite/client.js +2 -0
  85. package/dist/vitest-config.js +3 -0
  86. package/dist/workspace-connections/actions/list-workspace-user-groups.d.ts +1 -1
  87. package/docs/content/deployment-environment-variables.mdx +1 -0
  88. package/docs/content/environment-variables.mdx +1 -0
  89. package/docs/content/sharing.mdx +36 -0
  90. package/docs/content/toolkit-sharing.mdx +98 -12
  91. package/package.json +5 -3
@@ -44,9 +44,22 @@ export declare function useShareMutations(): {
44
44
  share: import("@tanstack/react-query").UseMutationResult<any, Error, Record<string, any>, unknown>;
45
45
  unshare: import("@tanstack/react-query").UseMutationResult<any, Error, Record<string, any>, unknown>;
46
46
  };
47
+ /**
48
+ * Chat history lists are cached outside the share query, so a saved grant or
49
+ * visibility change must tell them to refetch. Other resource types have no
50
+ * such list to refresh.
51
+ */
52
+ export declare function notifyShareChanged(resourceType: string): void;
47
53
  export declare function optimisticallyUpdateShareCache<TData>(queryClient: QueryClient, queryKey: ShareQueryKey, updater: (previous: TData | undefined) => TData | undefined): TData | undefined;
48
54
  export declare function rollbackShareCache<TData>(queryClient: QueryClient, queryKey: ShareQueryKey, previous: TData | undefined): void;
49
- export declare function useShareMutationGuard(): {
55
+ export declare function optimisticallyUpdateShareVisibility<TData extends {
56
+ visibility: string | null;
57
+ }>(queryClient: QueryClient, queryKey: ShareQueryKey, visibility: TData["visibility"]): {
58
+ isCurrent: () => boolean;
59
+ commit: (next: TData["visibility"]) => void;
60
+ rollback: () => void;
61
+ };
62
+ export declare function useShareMutationGuard(scope?: string): {
50
63
  begin: () => number;
51
64
  isLatest: (requestId: number) => boolean;
52
65
  };
@@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { agentNativePath } from "../api-path.js";
4
4
  import { useActionMutation, useActionQuery } from "../use-action.js";
5
+ import { notifyChatThreadsUpdated } from "../use-chat-threads.js";
5
6
  export const DEFAULT_MEMBER_SUGGESTION_LIMIT = 25;
6
7
  export const DEFAULT_MEMBER_SEARCH_DEBOUNCE_MS = 140;
7
8
  export function createShareQueryParams(resourceType, resourceId) {
@@ -27,6 +28,15 @@ export function useShareMutations() {
27
28
  unshare: useActionMutation("unshare-resource"),
28
29
  };
29
30
  }
31
+ /**
32
+ * Chat history lists are cached outside the share query, so a saved grant or
33
+ * visibility change must tell them to refetch. Other resource types have no
34
+ * such list to refresh.
35
+ */
36
+ export function notifyShareChanged(resourceType) {
37
+ if (resourceType === "chat_thread")
38
+ notifyChatThreadsUpdated();
39
+ }
30
40
  export function optimisticallyUpdateShareCache(queryClient, queryKey, updater) {
31
41
  const previous = queryClient.getQueryData(queryKey);
32
42
  const next = updater(previous);
@@ -45,8 +55,43 @@ export function rollbackShareCache(queryClient, queryKey, previous) {
45
55
  }
46
56
  queryClient.setQueryData(queryKey, previous);
47
57
  }
48
- export function useShareMutationGuard() {
58
+ // Ownership follows the cache entry, not the mounted surface. Weak keys also
59
+ // retire it when a query is removed, without retaining resource ids forever.
60
+ const visibilityCacheOwners = new WeakMap();
61
+ export function optimisticallyUpdateShareVisibility(queryClient, queryKey, visibility) {
62
+ const cacheKey = queryKey;
63
+ const query = queryClient
64
+ .getQueryCache()
65
+ .build(queryClient, { queryKey: cacheKey });
66
+ const previous = queryClient.getQueryData(queryKey);
67
+ const owner = {};
68
+ visibilityCacheOwners.set(query, owner);
69
+ const isCurrent = () => queryClient.getQueryCache().find({ queryKey, exact: true }) === query &&
70
+ visibilityCacheOwners.get(query) === owner;
71
+ const commit = (next) => {
72
+ if (!isCurrent())
73
+ return;
74
+ queryClient.setQueryData(queryKey, (cached) => cached ? { ...cached, visibility: next } : cached);
75
+ };
76
+ commit(visibility);
77
+ return {
78
+ isCurrent,
79
+ commit,
80
+ rollback: () => {
81
+ if (previous &&
82
+ queryClient.getQueryData(queryKey)?.visibility === visibility) {
83
+ commit(previous.visibility);
84
+ }
85
+ },
86
+ };
87
+ }
88
+ export function useShareMutationGuard(scope) {
49
89
  const latestRequest = useRef(0);
90
+ const currentScope = useRef(scope);
91
+ if (currentScope.current !== scope) {
92
+ currentScope.current = scope;
93
+ latestRequest.current += 1;
94
+ }
50
95
  const begin = useCallback(() => {
51
96
  latestRequest.current += 1;
52
97
  return latestRequest.current;
@@ -1,4 +1,5 @@
1
1
  import { useActionQuery } from "../use-action.js";
2
+ import { type ShareVisibilityTransition } from "./useShareVisibilityTransition.js";
2
3
  export type ShareButtonVisibility = "private" | "org" | "public";
3
4
  export type ShareButtonRole = "viewer" | "commenter" | "editor" | "admin";
4
5
  export interface ShareButtonShare {
@@ -68,7 +69,18 @@ export interface ShareButtonController {
68
69
  setInviteEmail: (email: string) => void;
69
70
  sharesQuery: ReturnType<typeof useActionQuery<ShareButtonSharesResponse>>;
70
71
  visibilityOverride: ShareButtonVisibility | null;
72
+ /**
73
+ * @deprecated Use `visibilityTransition.request`, which this calls. It
74
+ * resolves only once the change is persisted. A direct change rejects when
75
+ * its save fails. A private-to-Organization change stays pending through
76
+ * `visibilityTransition.confirm()` failures (retry is possible) and rejects
77
+ * with `ShareVisibilityNotSavedError` once it is cancelled or invalidated.
78
+ * Without manage rights it
79
+ * sets `shareError` and resolves, as it always has.
80
+ */
71
81
  handleVisibilityChange: (visibility: ShareButtonVisibility) => Promise<void>;
82
+ /** Request, confirm and cancel a visibility change that needs confirming. */
83
+ visibilityTransition: ShareVisibilityTransition;
72
84
  data: ShareButtonSharesResponse | undefined;
73
85
  policy: {
74
86
  allowPublic: boolean;
@@ -1,18 +1,25 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useActionQuery } from "../use-action.js";
3
- import { DEFAULT_MEMBER_SEARCH_DEBOUNCE_MS, DEFAULT_MEMBER_SUGGESTION_LIMIT, extractShareErrorMessage, optimisticallyUpdateShareCache, rollbackShareCache, useShareOrgMemberSearch, useShareMutationGuard, useShareMutations, useShareQuery, } from "./share-controller-helpers.js";
3
+ import { DEFAULT_MEMBER_SEARCH_DEBOUNCE_MS, DEFAULT_MEMBER_SUGGESTION_LIMIT, extractShareErrorMessage, notifyShareChanged, optimisticallyUpdateShareCache, optimisticallyUpdateShareVisibility, rollbackShareCache, useShareOrgMemberSearch, useShareMutationGuard, useShareMutations, useShareQuery, } from "./share-controller-helpers.js";
4
+ import { useShareVisibilityTransition, } from "./useShareVisibilityTransition.js";
4
5
  export function useShareButtonController(options) {
5
6
  const [open, setOpen] = useState(false);
6
7
  const [inviteEmail, setInviteEmail] = useState("");
7
8
  const [selectedGroup, setSelectedGroup] = useState(null);
8
9
  const shareTabDefaultValue = options.shareTabs?.defaultValue ?? "share";
9
10
  const [activeShareTab, setActiveShareTab] = useState(shareTabDefaultValue);
10
- const [visibilityOverride, setVisibilityOverride] = useState(null);
11
- const visibilitySequenceStartRef = useRef(null);
11
+ const resourceKey = `${options.resourceType}:${options.resourceId}`;
12
+ // Tied to the resource it was made for: a click that lands on a new resource
13
+ // before any effect runs must not see the previous resource's value.
14
+ const [visibilityOverrideState, setVisibilityOverrideState] = useState(null);
15
+ const visibilityOverride = visibilityOverrideState?.resourceKey === resourceKey
16
+ ? visibilityOverrideState.value
17
+ : null;
18
+ const clearVisibilityOverride = useCallback((key, token) => setVisibilityOverrideState((state) => state?.resourceKey === key && state.token === token ? null : state), []);
12
19
  const appliedDefaultOpenRef = useRef(false);
13
20
  const { queryKey: shareQueryKey, query: sharesQuery, queryClient, } = useShareQuery(options.resourceType, options.resourceId, open, 0);
14
21
  const { setVisibility, share, unshare } = useShareMutations();
15
- const visibilityGuard = useShareMutationGuard();
22
+ const visibilityGuard = useShareMutationGuard(resourceKey);
16
23
  const data = sharesQuery.data;
17
24
  const canManage = data?.role === "owner" || data?.role === "admin";
18
25
  const [shareError, setShareError] = useState(null);
@@ -31,7 +38,6 @@ export function useShareButtonController(options) {
31
38
  setSelectedGroup(null);
32
39
  setShareMessage("");
33
40
  setMessageOpen(false);
34
- visibilitySequenceStartRef.current = null;
35
41
  }, [options.resourceId, options.resourceType]);
36
42
  useEffect(() => {
37
43
  if (!options.defaultOpen || appliedDefaultOpenRef.current)
@@ -43,62 +49,52 @@ export function useShareButtonController(options) {
43
49
  setActiveShareTab(value);
44
50
  options.shareTabs?.onValueChange?.(value);
45
51
  }, [options.shareTabs]);
46
- const handleVisibilityChange = useCallback((next) => {
52
+ const applyVisibility = useCallback(async (next) => {
47
53
  if (!canManage) {
48
54
  setShareError("Only owners and admins can change access.");
49
- return Promise.resolve();
55
+ return;
50
56
  }
51
57
  const requestId = visibilityGuard.begin();
52
- const previous = optimisticallyUpdateShareCache(queryClient, shareQueryKey, (prev) => (prev ? { ...prev, visibility: next } : prev));
53
- visibilitySequenceStartRef.current ??= { visibility, shares: previous };
54
- setVisibilityOverride(next);
55
- return new Promise((resolve, reject) => {
56
- setVisibility.mutate({
58
+ const token = {};
59
+ const mutation = optimisticallyUpdateShareVisibility(queryClient, shareQueryKey, next);
60
+ setVisibilityOverrideState({ resourceKey, token, value: next });
61
+ let result;
62
+ try {
63
+ // Settles for this call alone: per-call mutate callbacks are skipped
64
+ // once a newer call supersedes it or the observer unmounts.
65
+ result = await setVisibility.mutateAsync({
57
66
  resourceType: options.resourceType,
58
67
  resourceId: options.resourceId,
59
68
  visibility: next,
60
- }, {
61
- onSuccess: (result) => {
62
- const resultVisibility = typeof result === "object" &&
63
- result !== null &&
64
- "visibility" in result &&
65
- result.visibility;
66
- if (visibilityGuard.isLatest(requestId)) {
67
- if (visibilitySequenceStartRef.current?.visibility ===
68
- "private" &&
69
- (resultVisibility === "org" || resultVisibility === "public")) {
70
- options.onShareSuccess?.();
71
- }
72
- optimisticallyUpdateShareCache(queryClient, shareQueryKey, (prev) => prev
73
- ? {
74
- ...prev,
75
- visibility: resultVisibility ?? next,
76
- }
77
- : prev);
78
- }
79
- sharesQuery
80
- .refetch()
81
- .then(() => resolve())
82
- .catch(reject)
83
- .finally(() => {
84
- if (visibilityGuard.isLatest(requestId)) {
85
- setVisibilityOverride(null);
86
- visibilitySequenceStartRef.current = null;
87
- }
88
- });
89
- },
90
- onError: (error) => {
91
- if (visibilityGuard.isLatest(requestId)) {
92
- const sequenceStart = visibilitySequenceStartRef.current;
93
- setVisibilityOverride(null);
94
- visibilitySequenceStartRef.current = null;
95
- rollbackShareCache(queryClient, shareQueryKey, sequenceStart ? sequenceStart.shares : previous);
96
- void sharesQuery.refetch();
97
- }
98
- reject(error);
99
- },
100
69
  });
101
- });
70
+ }
71
+ catch (error) {
72
+ mutation.rollback();
73
+ clearVisibilityOverride(resourceKey, token);
74
+ if (visibilityGuard.isLatest(requestId)) {
75
+ void sharesQuery.refetch();
76
+ }
77
+ throw error;
78
+ }
79
+ notifyShareChanged(options.resourceType);
80
+ const resultVisibility = typeof result === "object" &&
81
+ result !== null &&
82
+ "visibility" in result &&
83
+ result.visibility;
84
+ mutation.commit(resultVisibility ?? next);
85
+ if (visibilityGuard.isLatest(requestId)) {
86
+ if (visibility === "private" &&
87
+ (resultVisibility === "org" || resultVisibility === "public")) {
88
+ options.onShareSuccess?.();
89
+ }
90
+ }
91
+ try {
92
+ if (visibilityGuard.isLatest(requestId))
93
+ await sharesQuery.refetch();
94
+ }
95
+ finally {
96
+ clearVisibilityOverride(resourceKey, token);
97
+ }
102
98
  }, [
103
99
  options.resourceId,
104
100
  options.resourceType,
@@ -110,6 +106,8 @@ export function useShareButtonController(options) {
110
106
  visibilityGuard,
111
107
  options.onShareSuccess,
112
108
  visibility,
109
+ resourceKey,
110
+ clearVisibilityOverride,
113
111
  ]);
114
112
  const policy = data?.policy ?? {
115
113
  allowPublic: true,
@@ -187,6 +185,17 @@ export function useShareButtonController(options) {
187
185
  setSelectedGroup(group);
188
186
  setInviteEmail(group.name);
189
187
  }, []);
188
+ const visibilityTransition = useShareVisibilityTransition({
189
+ resourceType: options.resourceType,
190
+ resourceId: options.resourceId,
191
+ current: visibility,
192
+ orgId: data?.orgId ?? null,
193
+ canManage,
194
+ active: open,
195
+ apply: applyVisibility,
196
+ onDirectError: (error) => setShareError(extractShareErrorMessage(error)),
197
+ });
198
+ const requestVisibility = visibilityTransition.request;
190
199
  const handleVisibility = useCallback((next) => {
191
200
  if (next === visibility)
192
201
  return;
@@ -195,10 +204,16 @@ export function useShareButtonController(options) {
195
204
  return;
196
205
  }
197
206
  setShareError(null);
198
- void handleVisibilityChange(next).catch((error) => {
199
- setShareError(extractShareErrorMessage(error));
200
- });
201
- }, [canManage, handleVisibilityChange, visibility]);
207
+ void requestVisibility(next);
208
+ }, [canManage, requestVisibility, visibility]);
209
+ const handleVisibilityChange = useCallback((next) => {
210
+ if (!canManage) {
211
+ setShareError("Only owners and admins can change access.");
212
+ return Promise.resolve();
213
+ }
214
+ setShareError(null);
215
+ return requestVisibility(next);
216
+ }, [canManage, requestVisibility]);
202
217
  const handleHideInSearch = useCallback(() => {
203
218
  const control = options.hideInSearchControl;
204
219
  if (!control || control.pending || !canManage)
@@ -248,6 +263,7 @@ export function useShareButtonController(options) {
248
263
  ...(message ? { message } : {}),
249
264
  }, {
250
265
  onSuccess: (result) => {
266
+ notifyShareChanged(options.resourceType);
251
267
  if (!(typeof result === "object" &&
252
268
  result !== null &&
253
269
  "updated" in result &&
@@ -315,6 +331,7 @@ export function useShareButtonController(options) {
315
331
  notify: false,
316
332
  }, {
317
333
  onSuccess: () => {
334
+ notifyShareChanged(options.resourceType);
318
335
  void sharesQuery.refetch().then(() => {
319
336
  setRoleOverrides((previous) => {
320
337
  const { [key]: _removed, ...rest } = previous;
@@ -367,6 +384,7 @@ export function useShareButtonController(options) {
367
384
  principalId: currentShare.principalId,
368
385
  }, {
369
386
  onSuccess: () => {
387
+ notifyShareChanged(options.resourceType);
370
388
  void sharesQuery.refetch().then(() => {
371
389
  setPendingRemoves((previous) => {
372
390
  const next = new Set(previous);
@@ -408,6 +426,7 @@ export function useShareButtonController(options) {
408
426
  sharesQuery,
409
427
  visibilityOverride,
410
428
  handleVisibilityChange,
429
+ visibilityTransition,
411
430
  data,
412
431
  policy,
413
432
  visibility,
@@ -1,3 +1,4 @@
1
+ import { type ShareVisibilityTransition } from "./useShareVisibilityTransition.js";
1
2
  export type ShareVisibility = "private" | "org" | "public";
2
3
  export type ShareRole = "viewer" | "commenter" | "editor" | "admin";
3
4
  export type ShareDialogTab = "link" | "invite" | "embed";
@@ -82,7 +83,9 @@ export interface ShareDialogController {
82
83
  options: Array<ShareOption<ShareVisibility>>;
83
84
  disabled: boolean;
84
85
  pending: boolean;
86
+ /** Saves at once, or holds a broadening change for `transition`. */
85
87
  set: (visibility: ShareVisibility) => void;
88
+ transition: ShareVisibilityTransition;
86
89
  };
87
90
  invite: {
88
91
  email: string;
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { trackEvent } from "../analytics.js";
3
3
  import { useT } from "../i18n.js";
4
- import { extractShareErrorMessage, optimisticallyUpdateShareCache, rollbackShareCache, useShareMutationGuard, useShareMutations, useShareOrgMemberSearch, useShareQuery, } from "./share-controller-helpers.js";
4
+ import { extractShareErrorMessage, notifyShareChanged, optimisticallyUpdateShareCache, optimisticallyUpdateShareVisibility, rollbackShareCache, useShareMutationGuard, useShareMutations, useShareOrgMemberSearch, useShareQuery, } from "./share-controller-helpers.js";
5
+ import { useShareVisibilityTransition, } from "./useShareVisibilityTransition.js";
5
6
  async function writeBrowserClipboardText(text) {
6
7
  if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
7
8
  return false;
@@ -34,9 +35,16 @@ export function useShareDialogController({ open, onClose, resourceType, resource
34
35
  const [message, setMessage] = useState("");
35
36
  const [messageOpen, setMessageOpen] = useState(false);
36
37
  const [copiedField, setCopiedField] = useState(null);
37
- const [visibilityOverride, setVisibilityOverride] = useState(null);
38
+ const resourceKey = `${resourceType}:${resourceId}`;
39
+ // Tied to the resource it was made for: a click that lands on a new resource
40
+ // before any effect runs must not see the previous resource's value.
41
+ const [visibilityOverrideState, setVisibilityOverrideState] = useState(null);
42
+ const visibilityOverride = visibilityOverrideState?.resourceKey === resourceKey
43
+ ? visibilityOverrideState.value
44
+ : null;
45
+ const clearVisibilityOverride = useCallback((key, token) => setVisibilityOverrideState((state) => state?.resourceKey === key && state.token === token ? null : state), []);
38
46
  const [mutationError, setMutationError] = useState(null);
39
- const visibilityGuard = useShareMutationGuard();
47
+ const visibilityGuard = useShareMutationGuard(resourceKey);
40
48
  const copyResetTimer = useRef(null);
41
49
  useEffect(() => {
42
50
  if (open)
@@ -56,61 +64,76 @@ export function useShareDialogController({ open, onClose, resourceType, resource
56
64
  .map((value) => visibilityOption(value, t)), [data?.policy?.allowPublic, t, visibility]);
57
65
  const roleOptions = useMemo(() => ["viewer", "commenter", "editor", "admin"].map((value) => roleOption(value, t)), [t]);
58
66
  const refetch = useCallback(() => sharesQuery.refetch(), [sharesQuery]);
59
- const setVisibility = useCallback((next) => {
60
- if (!canManage || next === visibility)
61
- return;
67
+ const applyVisibility = useCallback(async (next) => {
62
68
  const requestId = visibilityGuard.begin();
63
- const previous = optimisticallyUpdateShareCache(queryClient, shareQueryKey, (cached) => (cached ? { ...cached, visibility: next } : cached));
69
+ const token = {};
70
+ const mutation = optimisticallyUpdateShareVisibility(queryClient, shareQueryKey, next);
64
71
  setMutationError(null);
65
- setVisibilityOverride(next);
66
- visibilityMutation.mutate({ resourceType, resourceId, visibility: next }, {
67
- onSuccess: (result) => {
68
- if (visibilityGuard.isLatest(requestId)) {
69
- const resultVisibility = typeof result === "object" &&
70
- result !== null &&
71
- "visibility" in result &&
72
- result.visibility;
73
- optimisticallyUpdateShareCache(queryClient, shareQueryKey, (cached) => cached
74
- ? {
75
- ...cached,
76
- visibility: resultVisibility ??
77
- next,
78
- }
79
- : cached);
80
- }
81
- if (!visibilityGuard.isLatest(requestId))
82
- return;
83
- Promise.resolve(refetch())
84
- .catch((error) => {
85
- if (visibilityGuard.isLatest(requestId)) {
86
- setMutationError(extractShareErrorMessage(error));
87
- }
88
- })
89
- .finally(() => {
90
- if (visibilityGuard.isLatest(requestId)) {
91
- setVisibilityOverride(null);
92
- }
93
- });
94
- },
95
- onError: (error) => {
96
- if (visibilityGuard.isLatest(requestId)) {
97
- rollbackShareCache(queryClient, shareQueryKey, previous);
98
- setVisibilityOverride(null);
99
- setMutationError(extractShareErrorMessage(error));
100
- }
101
- },
72
+ setVisibilityOverrideState({ resourceKey, token, value: next });
73
+ let result;
74
+ try {
75
+ // Settles for this call alone: per-call mutate callbacks are skipped
76
+ // once a newer call supersedes it or the observer unmounts.
77
+ result = await visibilityMutation.mutateAsync({
78
+ resourceType,
79
+ resourceId,
80
+ visibility: next,
81
+ });
82
+ }
83
+ catch (error) {
84
+ mutation.rollback();
85
+ clearVisibilityOverride(resourceKey, token);
86
+ if (visibilityGuard.isLatest(requestId) && mutation.isCurrent()) {
87
+ void queryClient.refetchQueries({ queryKey: shareQueryKey, exact: true, type: "active" }, { throwOnError: false });
88
+ }
89
+ throw error;
90
+ }
91
+ notifyShareChanged(resourceType);
92
+ const resultVisibility = typeof result === "object" &&
93
+ result !== null &&
94
+ "visibility" in result &&
95
+ result.visibility;
96
+ mutation.commit(resultVisibility ?? next);
97
+ if (!visibilityGuard.isLatest(requestId)) {
98
+ clearVisibilityOverride(resourceKey, token);
99
+ return;
100
+ }
101
+ void Promise.resolve(refetch())
102
+ .catch((error) => {
103
+ if (visibilityGuard.isLatest(requestId)) {
104
+ setMutationError(extractShareErrorMessage(error));
105
+ }
106
+ })
107
+ .finally(() => {
108
+ clearVisibilityOverride(resourceKey, token);
102
109
  });
103
110
  }, [
104
- canManage,
105
111
  refetch,
106
112
  resourceId,
107
113
  resourceType,
114
+ resourceKey,
115
+ clearVisibilityOverride,
108
116
  queryClient,
109
117
  shareQueryKey,
110
- visibility,
111
118
  visibilityGuard,
112
119
  visibilityMutation,
113
120
  ]);
121
+ const visibilityTransition = useShareVisibilityTransition({
122
+ resourceType,
123
+ resourceId,
124
+ current: visibility,
125
+ orgId: data?.orgId ?? null,
126
+ canManage,
127
+ active: open,
128
+ apply: applyVisibility,
129
+ onDirectError: (error) => setMutationError(extractShareErrorMessage(error)),
130
+ });
131
+ const requestVisibility = visibilityTransition.request;
132
+ // A void setter: callers that return it from a handler must not end up
133
+ // awaiting a change that may sit waiting for confirmation.
134
+ const setVisibility = useCallback((next) => {
135
+ void requestVisibility(next);
136
+ }, [requestVisibility]);
114
137
  const submitInvite = useCallback(() => {
115
138
  const principalId = email.trim();
116
139
  if (!canManage || !principalId)
@@ -135,6 +158,7 @@ export function useShareDialogController({ open, onClose, resourceType, resource
135
158
  ...(notificationMessage ? { message: notificationMessage } : {}),
136
159
  }, {
137
160
  onSuccess: () => {
161
+ notifyShareChanged(resourceType);
138
162
  setEmail("");
139
163
  setMessage("");
140
164
  setMessageOpen(false);
@@ -176,7 +200,10 @@ export function useShareDialogController({ open, onClose, resourceType, resource
176
200
  principalType: share.principalType,
177
201
  principalId: share.principalId,
178
202
  }, {
179
- onSuccess: refetch,
203
+ onSuccess: () => {
204
+ notifyShareChanged(resourceType);
205
+ return refetch();
206
+ },
180
207
  onError: (error) => {
181
208
  rollbackShareCache(queryClient, shareQueryKey, previous);
182
209
  setMutationError(extractShareErrorMessage(error));
@@ -260,9 +287,10 @@ export function useShareDialogController({ open, onClose, resourceType, resource
260
287
  label: currentVisibility.label,
261
288
  description: currentVisibility.description,
262
289
  options: visibilityOptions,
263
- disabled: !canManage,
290
+ disabled: !canManage || visibilityTransition.submitting,
264
291
  pending: visibilityMutation.isPending,
265
292
  set: setVisibility,
293
+ transition: visibilityTransition,
266
294
  },
267
295
  invite: {
268
296
  email,
@@ -290,7 +318,7 @@ export function useShareDialogController({ open, onClose, resourceType, resource
290
318
  copiedField,
291
319
  copy,
292
320
  loading: sharesQuery.isLoading,
293
- error: sharesQuery.error ?? mutationError,
321
+ error: mutationError ?? sharesQuery.error,
294
322
  refetch,
295
323
  canManage,
296
324
  agentReadable: data?.agentReadable === true,
@@ -0,0 +1,68 @@
1
+ import type { ShareVisibility } from "./useShareDialogController.js";
2
+ /**
3
+ * Whether moving to `to` shows more of the resource to more people than `from`
4
+ * does and so needs an explicit confirmation. Public -> organization narrows
5
+ * access and Public stays confirmation-free, so only private -> organization
6
+ * asks.
7
+ */
8
+ export declare function visibilityNeedsConfirmation(from: ShareVisibility, to: ShareVisibility): boolean;
9
+ /**
10
+ * Why a requested visibility change was never saved: `ignored` when the
11
+ * request was refused up front (closed surface, no manage rights, a save
12
+ * already running) and `dropped` when it was held for confirmation and then
13
+ * cancelled or invalidated.
14
+ */
15
+ export declare class ShareVisibilityNotSavedError extends Error {
16
+ readonly reason: "ignored" | "dropped";
17
+ constructor(reason: "ignored" | "dropped");
18
+ }
19
+ export interface ShareVisibilityTransitionOptions {
20
+ resourceType: string;
21
+ resourceId: string;
22
+ /** The visibility shown right now, including an in-flight optimistic one. */
23
+ current: ShareVisibility;
24
+ /** The organization the resource belongs to, from the share listing. */
25
+ orgId: string | null;
26
+ canManage: boolean;
27
+ /** False while the share surface is closed; a pending request is dropped. */
28
+ active: boolean;
29
+ /** Saves the visibility. Rejects on failure so the caller can show it. */
30
+ apply: (next: ShareVisibility) => Promise<void>;
31
+ /**
32
+ * Receives a failure of a change that needed no confirmation. Required so a
33
+ * failed save is never left looking like a successful one.
34
+ */
35
+ onDirectError: (error: unknown) => void;
36
+ }
37
+ export interface ShareVisibilityTransitionRequest {
38
+ from: ShareVisibility;
39
+ to: ShareVisibility;
40
+ }
41
+ export interface ShareVisibilityTransition {
42
+ /** The change waiting for confirmation, or null. Nothing is saved yet. */
43
+ pending: ShareVisibilityTransitionRequest | null;
44
+ /**
45
+ * The resource's organization name, only when it was read for that exact
46
+ * organization. Null means "say your organization" instead of a name.
47
+ */
48
+ organizationName: string | null;
49
+ submitting: boolean;
50
+ /** Why the last confirmation failed; the request stays pending to retry. */
51
+ error: string | null;
52
+ /**
53
+ * Saves `next` at once, or holds it for confirmation when it broadens.
54
+ * The promise resolves only once the change is persisted. A change that
55
+ * needs no confirmation rejects with the save's failure. A held change that
56
+ * fails when confirmed keeps its `error` and stays pending so the user can
57
+ * retry, and its promise stays pending too: it resolves after a successful
58
+ * retry and rejects with `ShareVisibilityNotSavedError` once the change is
59
+ * cancelled or invalidated (resource change, closed surface, lost manage
60
+ * rights). A request that is refused up front rejects the same way.
61
+ * While a save runs, the same target joins it and a different target is
62
+ * refused with reason `ignored` until the write settles.
63
+ */
64
+ request: (next: ShareVisibility) => Promise<void>;
65
+ confirm: () => Promise<void>;
66
+ cancel: () => void;
67
+ }
68
+ export declare function useShareVisibilityTransition(options: ShareVisibilityTransitionOptions): ShareVisibilityTransition;