@agent-native/core 0.161.10 → 0.161.12

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 (27) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/export-document.ts +68 -3
  3. package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
  4. package/corpus/templates/content/shared/document-export.ts +25 -0
  5. package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
  6. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
  7. package/corpus/templates/design/app/pages/Index.tsx +45 -1
  8. package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
  9. package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
  10. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
  11. package/dist/action.js +19 -2
  12. package/dist/agent/chat-health-alert.js +10 -1
  13. package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
  14. package/dist/client/embed-auth.js +40 -3
  15. package/dist/client/route-state.js +42 -5
  16. package/dist/client/use-change-version.js +71 -25
  17. package/dist/client/use-db-sync.d.ts +1 -0
  18. package/dist/client/use-db-sync.js +223 -45
  19. package/dist/collab/awareness.d.ts +2 -2
  20. package/dist/collab/awareness.js +37 -4
  21. package/dist/collab/client.js +17 -1
  22. package/dist/collab/struct-routes.d.ts +1 -1
  23. package/dist/notifications/routes.d.ts +1 -1
  24. package/dist/progress/routes.d.ts +1 -1
  25. package/dist/server/poll.d.ts +22 -14
  26. package/dist/server/poll.js +243 -67
  27. package/package.json +1 -1
package/dist/action.js CHANGED
@@ -326,8 +326,8 @@ const SUBSCHEMA_MAP_KEYS = [
326
326
  "dependentSchemas",
327
327
  ];
328
328
  /**
329
- * Remove JSON Schema keywords that some providers' function-calling schema
330
- * validators reject. OpenAI (and Gemini via the Builder gateway) reject
329
+ * Remove or rewrite JSON Schema keywords that some providers' function-calling
330
+ * schema validators reject. OpenAI (and Gemini via the Builder gateway) reject
331
331
  * `propertyNames` — which Zod v4 emits for `z.record(z.string(), …)` — with a
332
332
  * `400 invalid_function_parameters` error, causing the model turn to produce no
333
333
  * content (surfacing as an empty assistant response). Anthropic ignores the
@@ -335,6 +335,15 @@ const SUBSCHEMA_MAP_KEYS = [
335
335
  * portable. `propertyNames` only constrained object *keys*; the value/shape of
336
336
  * the object is unaffected by its removal.
337
337
  *
338
+ * OpenAI rejects `oneOf` the same way — "Invalid schema for function 'x': …
339
+ * 'oneOf' is not permitted" — while accepting `anyOf`. Zod v4's `toJSONSchema`
340
+ * emits `oneOf` for every `z.discriminatedUnion`, so any action with one was
341
+ * 400'd for the whole request before a token streamed, and the gateway folded
342
+ * that 400 into a generic 500. Measured: 178k events / 786 users over seven
343
+ * weeks from a single action. For a discriminated union the two keywords accept
344
+ * the same documents, and the tool-input validator already handles both
345
+ * (see `narrowUnionBranchErrors`), so the rewrite is behaviour-preserving.
346
+ *
338
347
  * Only descends through actual subschema positions (properties, items, union
339
348
  * branches, definitions, etc.) — never through value-bearing keywords like
340
349
  * `default`, `const`, `enum`, or `examples`, whose objects may legitimately
@@ -371,6 +380,14 @@ function stripUnsupportedSchemaKeywords(node) {
371
380
  }
372
381
  }
373
382
  }
383
+ // After descending, so nested unions are rewritten too. Merged rather than
384
+ // overwritten on the pathological case where a schema carries both: dropping
385
+ // either arm would silently narrow what the tool accepts.
386
+ if (Array.isArray(obj.oneOf)) {
387
+ const existing = Array.isArray(obj.anyOf) ? obj.anyOf : [];
388
+ obj.anyOf = [...existing, ...obj.oneOf];
389
+ delete obj.oneOf;
390
+ }
374
391
  return node;
375
392
  }
376
393
  /**
@@ -84,7 +84,16 @@ export async function checkChatHealthAndAlert(now = Date.now()) {
84
84
  if (badRate < BAD_RATE_THRESHOLD) {
85
85
  return { status: "healthy", turns: counts.turns, badRate };
86
86
  }
87
- const stored = await getSetting(LAST_ALERT_SETTING_KEY).catch(() => null);
87
+ // A cooldown stamp we could not READ is not a cooldown that is absent:
88
+ // treating it as absent pages on every sweep for as long as settings stay
89
+ // unreadable, which is exactly the spam the cooldown exists to prevent.
90
+ let stored;
91
+ try {
92
+ stored = (await getSetting(LAST_ALERT_SETTING_KEY));
93
+ }
94
+ catch (error) {
95
+ return { status: "check-failed", reason: String(error) };
96
+ }
88
97
  const lastPagedAt = Number(stored?.at ?? 0);
89
98
  if (Number.isFinite(lastPagedAt) && now - lastPagedAt < COOLDOWN_MS) {
90
99
  return {
@@ -50,7 +50,27 @@ export function useNearBottomAutoscroll({ followKey, streaming = false, threshol
50
50
  const followGenerationRef = useRef(0);
51
51
  const lastScrollTopRef = useRef(0);
52
52
  const lastTouchYRef = useRef(null);
53
+ const pendingAnimationFrameIdsRef = useRef(new Set());
54
+ const pendingTimeoutIdsRef = useRef(new Set());
53
55
  const [showScrollToBottom, setShowScrollToBottom] = useState(false);
56
+ const cancelPendingScrolls = useCallback(() => {
57
+ for (const id of pendingAnimationFrameIdsRef.current) {
58
+ window.cancelAnimationFrame(id);
59
+ }
60
+ pendingAnimationFrameIdsRef.current.clear();
61
+ for (const id of pendingTimeoutIdsRef.current) {
62
+ window.clearTimeout(id);
63
+ }
64
+ pendingTimeoutIdsRef.current.clear();
65
+ }, []);
66
+ const scheduleAnimationFrame = useCallback((callback) => {
67
+ let id = 0;
68
+ id = window.requestAnimationFrame((time) => {
69
+ pendingAnimationFrameIdsRef.current.delete(id);
70
+ callback(time);
71
+ });
72
+ pendingAnimationFrameIdsRef.current.add(id);
73
+ }, []);
54
74
  const isAtBottom = useCallback((el) => el.scrollHeight - el.scrollTop - el.clientHeight <= threshold, [threshold]);
55
75
  const setFollowingBottom = useCallback((following, forceGeneration = false, el) => {
56
76
  if (forceGeneration || isNearBottomRef.current !== following) {
@@ -213,14 +233,24 @@ export function useNearBottomAutoscroll({ followKey, streaming = false, threshol
213
233
  };
214
234
  }, [detachFromBottom, enabled, scrollToBottomIfFollowing, updateBottomState]);
215
235
  const scrollToBottomAfterPaint = useCallback(() => {
236
+ cancelPendingScrolls();
216
237
  const generation = followGenerationRef.current;
217
238
  scrollToBottomIfFollowing(generation);
218
- requestAnimationFrame(() => {
239
+ scheduleAnimationFrame(() => {
219
240
  scrollToBottomIfFollowing(generation);
220
- requestAnimationFrame(() => scrollToBottomIfFollowing(generation));
241
+ scheduleAnimationFrame(() => scrollToBottomIfFollowing(generation));
221
242
  });
222
- window.setTimeout(() => scrollToBottomIfFollowing(generation), 80);
223
- }, [scrollToBottomIfFollowing]);
243
+ const timeoutId = window.setTimeout(() => {
244
+ pendingTimeoutIdsRef.current.delete(timeoutId);
245
+ scrollToBottomIfFollowing(generation);
246
+ }, 80);
247
+ pendingTimeoutIdsRef.current.add(timeoutId);
248
+ }, [cancelPendingScrolls, scheduleAnimationFrame, scrollToBottomIfFollowing]);
249
+ useEffect(() => {
250
+ if (!enabled)
251
+ cancelPendingScrolls();
252
+ return cancelPendingScrolls;
253
+ }, [cancelPendingScrolls, enabled]);
224
254
  const resumeFollowing = useCallback(() => {
225
255
  const el = scrollRef.current;
226
256
  setFollowingBottom(true, true, el ?? undefined);
@@ -11,6 +11,7 @@ const GUARDED_METHODS = new Set(["GET", "HEAD"]);
11
11
  const AUTH_FAILURE_HEADER = "x-agent-native-auth-circuit-breaker";
12
12
  const MCP_CHAT_BRIDGE_VIEWPORT_STYLE_ID = "agent-native-mcp-chat-bridge-viewport";
13
13
  const MCP_CHAT_BRIDGE_VIEWPORT_HEIGHT = 560;
14
+ let pendingMcpChatBridgeViewportNotification = null;
14
15
  const authFailureCache = new Map();
15
16
  let embedAuthFailure = null;
16
17
  function browserWindow() {
@@ -211,11 +212,38 @@ function notifyMcpChatBridgeViewportHeight(win) {
211
212
  // Cross-host embeds can deny parent messaging in tests or strict sandboxes.
212
213
  }
213
214
  };
215
+ const pending = pendingMcpChatBridgeViewportNotification;
216
+ pendingMcpChatBridgeViewportNotification = null;
217
+ if (pending) {
218
+ if (pending.animationFrameId !== null) {
219
+ pending.win.cancelAnimationFrame?.(pending.animationFrameId);
220
+ }
221
+ for (const id of pending.timeoutIds)
222
+ pending.win.clearTimeout(id);
223
+ }
214
224
  notify();
225
+ const nextPending = {
226
+ win,
227
+ animationFrameId: null,
228
+ timeoutIds: [],
229
+ };
230
+ pendingMcpChatBridgeViewportNotification = nextPending;
231
+ const notifyIfCurrent = () => {
232
+ // Some hosts expose requestAnimationFrame/timers from a different clock
233
+ // than the one used by clearTimeout. The identity guard keeps a superseded
234
+ // setup from notifying even when cancellation cannot reach that clock.
235
+ if (pendingMcpChatBridgeViewportNotification !== nextPending)
236
+ return;
237
+ notify();
238
+ };
215
239
  try {
216
- win.requestAnimationFrame?.(() => notify());
217
- win.setTimeout?.(notify, 250);
218
- win.setTimeout?.(notify, 1000);
240
+ if (win.requestAnimationFrame) {
241
+ nextPending.animationFrameId = win.requestAnimationFrame(notifyIfCurrent);
242
+ }
243
+ if (win.setTimeout) {
244
+ nextPending.timeoutIds.push(win.setTimeout(notifyIfCurrent, 250));
245
+ nextPending.timeoutIds.push(win.setTimeout(notifyIfCurrent, 1000));
246
+ }
219
247
  }
220
248
  catch {
221
249
  // Timers are a progressive enhancement for late host bridge initialization.
@@ -223,6 +251,15 @@ function notifyMcpChatBridgeViewportHeight(win) {
223
251
  }
224
252
  /** Internal test helper. Do not use in app code. */
225
253
  export function _resetEmbedAuthForTests() {
254
+ if (pendingMcpChatBridgeViewportNotification) {
255
+ const pending = pendingMcpChatBridgeViewportNotification;
256
+ pendingMcpChatBridgeViewportNotification = null;
257
+ if (pending.animationFrameId !== null) {
258
+ pending.win.cancelAnimationFrame?.(pending.animationFrameId);
259
+ }
260
+ for (const id of pending.timeoutIds)
261
+ pending.win.clearTimeout(id);
262
+ }
226
263
  installed = false;
227
264
  memoryToken = null;
228
265
  mcpChatBridgeActive = false;
@@ -37,12 +37,39 @@ function defaultCommandDedupKey(command) {
37
37
  function currentRouterPath(location) {
38
38
  return `${location.pathname}${location.search}${location.hash}`;
39
39
  }
40
+ function shallowEqualNavigationState(left, right) {
41
+ if (Object.is(left, right))
42
+ return true;
43
+ if (!left ||
44
+ !right ||
45
+ typeof left !== "object" ||
46
+ typeof right !== "object") {
47
+ return false;
48
+ }
49
+ const leftKeys = Object.keys(left);
50
+ const rightKeys = Object.keys(right);
51
+ if (leftKeys.length !== rightKeys.length)
52
+ return false;
53
+ for (const key of leftKeys) {
54
+ if (!Object.prototype.hasOwnProperty.call(right, key) ||
55
+ !Object.is(left[key], right[key])) {
56
+ return false;
57
+ }
58
+ }
59
+ return true;
60
+ }
40
61
  function stringifyForWriteDedup(value) {
41
62
  try {
42
- return JSON.stringify(value);
63
+ const serialized = JSON.stringify(value);
64
+ return typeof serialized === "string"
65
+ ? serialized
66
+ : Symbol("unserializable navigation state");
43
67
  }
44
68
  catch {
45
- return "";
69
+ // Do not turn a serialization failure into a reusable string key. A
70
+ // distinct token lets a changed unserializable state reach the write path
71
+ // and surface its real error instead of being silently deduplicated.
72
+ return Symbol("unserializable navigation state");
46
73
  }
47
74
  }
48
75
  function resolveAgentChatViewTransitionOption(option, command, path) {
@@ -65,10 +92,10 @@ export function useSemanticNavigationState(options) {
65
92
  const commandKeys = useMemo(() => uniqueKeys(options.commandKeys ?? ["navigate"]), [options.commandKeys]);
66
93
  const commandQueryKey = useMemo(() => options.commandQueryKey ?? ["navigate-command"], [options.commandQueryKey]);
67
94
  const navigationState = options.state ?? null;
68
- const navigationWriteDedup = stringifyForWriteDedup({
95
+ const navigationWriteDedup = useMemo(() => stringifyForWriteDedup({
69
96
  keys: navigationKeys,
70
97
  state: navigationState,
71
- });
98
+ }), [navigationKeys, navigationState]);
72
99
  const getCommandDedupKeyRef = useRef(options.getCommandDedupKey);
73
100
  const onCommandRef = useRef(options.onCommand);
74
101
  const onErrorRef = useRef(options.onError);
@@ -184,7 +211,17 @@ export function useAgentRouteState(options) {
184
211
  browserTabId ?? "global",
185
212
  ], [browserTabId, commandKey, options.commandQueryKey]);
186
213
  const routeLocation = useMemo(() => routeLocationFromReactRouter(location), [location]);
187
- const navigationState = options.getNavigationState(routeLocation) ?? null;
214
+ // Callers may include app-local state in this callback in addition to the
215
+ // router location. Derive on every render so those values are not frozen at
216
+ // the last route change, then retain the previous object when its shallow
217
+ // values are unchanged. This keeps the write-dedup serialization off the
218
+ // unrelated-render path without hiding captured state updates.
219
+ const derivedNavigationState = options.getNavigationState(routeLocation) ?? null;
220
+ const navigationStateRef = useRef(derivedNavigationState);
221
+ if (!shallowEqualNavigationState(navigationStateRef.current, derivedNavigationState)) {
222
+ navigationStateRef.current = derivedNavigationState;
223
+ }
224
+ const navigationState = navigationStateRef.current;
188
225
  return useSemanticNavigationState({
189
226
  state: navigationState,
190
227
  navigationKeys,
@@ -31,11 +31,13 @@
31
31
  * refetch when that source fires. A poll heartbeat with no event does
32
32
  * nothing.
33
33
  */
34
- import { useSyncExternalStore } from "react";
34
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
35
+ const MAX_TRACKED_SOURCES = 1_000;
36
+ const ZERO_SNAPSHOT = () => 0;
35
37
  class ChangeVersionStore {
36
38
  versions = new Map();
37
- listeners = new Set();
38
- cachedSnapshots = new Map();
39
+ listeners = new Map();
40
+ activeSources = new Map();
39
41
  /**
40
42
  * Advance the counter for `source` to at least `version`. Returns true if
41
43
  * the counter actually moved (so callers can avoid spurious work).
@@ -46,31 +48,60 @@ class ChangeVersionStore {
46
48
  const current = this.versions.get(source) ?? 0;
47
49
  if (version > current) {
48
50
  this.versions.set(source, version);
49
- this.cachedSnapshots.set(source, version);
50
- this.notify();
51
+ this.evictUnobservedSources();
52
+ for (const listener of this.listeners.get(source) ?? [])
53
+ listener();
51
54
  return true;
52
55
  }
53
56
  return false;
54
57
  }
55
58
  get(source) {
56
- // useSyncExternalStore requires reference-stable snapshots for unchanged
57
- // values, so we serve from a cached map that we only update inside
58
- // `bump`.
59
- const cached = this.cachedSnapshots.get(source);
60
- if (cached !== undefined)
61
- return cached;
62
- this.cachedSnapshots.set(source, 0);
63
- return 0;
59
+ return this.versions.get(source) ?? 0;
64
60
  }
65
- subscribe(listener) {
66
- this.listeners.add(listener);
61
+ subscribe(sources, listener) {
62
+ const uniqueSources = new Set(sources.filter(Boolean));
63
+ for (const source of uniqueSources) {
64
+ this.activeSources.set(source, (this.activeSources.get(source) ?? 0) + 1);
65
+ let listeners = this.listeners.get(source);
66
+ if (!listeners) {
67
+ listeners = new Set();
68
+ this.listeners.set(source, listeners);
69
+ }
70
+ listeners.add(listener);
71
+ }
67
72
  return () => {
68
- this.listeners.delete(listener);
73
+ for (const source of uniqueSources) {
74
+ const listeners = this.listeners.get(source);
75
+ listeners?.delete(listener);
76
+ if (listeners?.size === 0)
77
+ this.listeners.delete(source);
78
+ const count = (this.activeSources.get(source) ?? 1) - 1;
79
+ if (count > 0)
80
+ this.activeSources.set(source, count);
81
+ else
82
+ this.activeSources.delete(source);
83
+ }
84
+ this.evictUnobservedSources();
69
85
  };
70
86
  }
71
- notify() {
72
- for (const listener of this.listeners)
73
- listener();
87
+ reset() {
88
+ this.versions.clear();
89
+ this.listeners.clear();
90
+ this.activeSources.clear();
91
+ }
92
+ evictUnobservedSources() {
93
+ while (this.versions.size > MAX_TRACKED_SOURCES) {
94
+ let evicted = false;
95
+ for (const source of this.versions.keys()) {
96
+ if (this.activeSources.has(source))
97
+ continue;
98
+ this.versions.delete(source);
99
+ evicted = true;
100
+ break;
101
+ }
102
+ if (!evicted)
103
+ return;
104
+ }
74
105
  }
75
106
  }
76
107
  const store = new ChangeVersionStore();
@@ -110,7 +141,9 @@ export function getChangeVersion(source) {
110
141
  * ```
111
142
  */
112
143
  export function useChangeVersion(source) {
113
- return useSyncExternalStore((cb) => store.subscribe(cb), () => store.get(source), () => 0);
144
+ const subscribe = useCallback((listener) => store.subscribe([source], listener), [source]);
145
+ const getSnapshot = useCallback(() => store.get(source), [source]);
146
+ return useSyncExternalStore(subscribe, getSnapshot, ZERO_SNAPSHOT);
114
147
  }
115
148
  /**
116
149
  * Convenience for queries that should refetch on multiple sources — returns
@@ -123,12 +156,25 @@ export function useChangeVersion(source) {
123
156
  * ```
124
157
  */
125
158
  export function useChangeVersions(sources) {
126
- return useSyncExternalStore((cb) => store.subscribe(cb), () => sources.reduce((sum, src) => sum + store.get(src), 0), () => 0);
159
+ const sourceKey = sources
160
+ .map((source) => `${source.length}:${source}`)
161
+ .join("\u0001");
162
+ const stableSources = useMemo(() => {
163
+ const uniqueSources = [];
164
+ const seen = new Set();
165
+ for (const source of sources) {
166
+ if (seen.has(source))
167
+ continue;
168
+ seen.add(source);
169
+ uniqueSources.push(source);
170
+ }
171
+ return uniqueSources;
172
+ }, [sourceKey]);
173
+ const subscribe = useCallback((listener) => store.subscribe(stableSources, listener), [stableSources]);
174
+ const getSnapshot = useCallback(() => stableSources.reduce((sum, src) => sum + store.get(src), 0), [stableSources]);
175
+ return useSyncExternalStore(subscribe, getSnapshot, ZERO_SNAPSHOT);
127
176
  }
128
177
  /** Internal test helper — reset all counters. Do not use in app code. */
129
178
  export function _resetChangeVersionStoreForTests() {
130
- // @ts-expect-error reaching past private to clear state in tests
131
- store.versions.clear();
132
- // @ts-expect-error reaching past private to clear state in tests
133
- store.cachedSnapshots.clear();
179
+ store.reset();
134
180
  }
@@ -18,6 +18,7 @@ interface QueryClient {
18
18
  }
19
19
  export type SyncEvent = {
20
20
  version?: number;
21
+ cursorId?: string;
21
22
  source?: string;
22
23
  type?: string;
23
24
  key?: string;