@agent-native/core 0.144.2 → 0.145.0

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 (104) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -4
  3. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +7 -3
  4. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +13 -3
  5. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +64 -23
  6. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +69 -46
  7. package/corpus/templates/calendar/app/hooks/use-view-preferences.ts +35 -6
  8. package/corpus/templates/calendar/app/hooks/use-zoom-auth.ts +24 -3
  9. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +45 -6
  10. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +38 -5
  11. package/corpus/templates/clips/changelog/2026-08-06-desktop-full-screen-recordings-now-normalize-microphone-leve.md +6 -0
  12. package/corpus/templates/clips/desktop/src/app.tsx +146 -45
  13. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +185 -69
  14. package/corpus/templates/clips/server/jobs/brain-export.ts +13 -6
  15. package/corpus/templates/clips/server/jobs/builder-media-compression.ts +10 -3
  16. package/corpus/templates/clips/server/jobs/meeting-reminders.ts +10 -3
  17. package/corpus/templates/clips/server/jobs/poll-calendars.ts +23 -21
  18. package/corpus/templates/clips/server/jobs/stale-meeting-sweeper.ts +10 -3
  19. package/corpus/templates/clips/server/jobs/transactional-emails.ts +10 -3
  20. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +1 -0
  21. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +15 -2
  22. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +47 -4
  23. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  24. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +82 -39
  25. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +31 -9
  26. package/corpus/templates/mail/server/plugins/mail-jobs.ts +36 -17
  27. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  28. package/dist/agent/production-agent.d.ts +6 -0
  29. package/dist/client/AssistantChat.js +73 -43
  30. package/dist/client/MultiTabAssistantChat.d.ts +3 -1
  31. package/dist/client/MultiTabAssistantChat.js +79 -92
  32. package/dist/client/browser-session-bridge.js +97 -54
  33. package/dist/client/db-admin/useAgentSync.js +34 -43
  34. package/dist/client/dynamic-suggestions.d.ts +3 -0
  35. package/dist/client/dynamic-suggestions.js +54 -52
  36. package/dist/client/hooks/index.d.ts +1 -0
  37. package/dist/client/hooks/index.js +1 -0
  38. package/dist/client/i18n.d.ts +7 -0
  39. package/dist/client/i18n.js +12 -3
  40. package/dist/client/integrations/useIntegrationStatus.d.ts +1 -1
  41. package/dist/client/integrations/useIntegrationStatus.js +9 -22
  42. package/dist/client/notifications/NotificationsBell.js +14 -9
  43. package/dist/client/progress/RunsTray.js +6 -5
  44. package/dist/client/settings/useBuilderStatus.js +95 -73
  45. package/dist/client/transcription/BuilderTranscriptionCta.js +27 -31
  46. package/dist/client/use-agent-engine-configured.js +21 -3
  47. package/dist/client/use-pausing-interval.d.ts +6 -0
  48. package/dist/client/use-pausing-interval.js +6 -0
  49. package/dist/client/use-poll-loop.d.ts +39 -0
  50. package/dist/client/use-poll-loop.js +73 -0
  51. package/dist/client/use-run-stuck-detection.js +41 -13
  52. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  53. package/dist/framework-tools.d.ts +126 -0
  54. package/dist/framework-tools.js +114 -0
  55. package/dist/integrations/google-docs-poller.js +26 -7
  56. package/dist/integrations/pending-tasks-retry-job.js +17 -23
  57. package/dist/integrations/plugin.js +16 -15
  58. package/dist/integrations/remote-push-delivery-job.js +16 -25
  59. package/dist/integrations/remote-retry-job.js +19 -23
  60. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  61. package/dist/notifications/routes.d.ts +3 -3
  62. package/dist/oauth-tokens/google-refresh.js +12 -19
  63. package/dist/observability/routes.d.ts +3 -3
  64. package/dist/progress/routes.d.ts +1 -1
  65. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  66. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  67. package/dist/resources/handlers.d.ts +1 -1
  68. package/dist/secrets/routes.d.ts +3 -3
  69. package/dist/server/action-discovery.d.ts +27 -0
  70. package/dist/server/action-discovery.js +89 -0
  71. package/dist/server/agent-chat/action-filters-a2a.d.ts +11 -0
  72. package/dist/server/agent-chat/action-filters-a2a.js +17 -1
  73. package/dist/server/agent-chat/framework-prompts.d.ts +2 -0
  74. package/dist/server/agent-chat/plugin-options.d.ts +30 -0
  75. package/dist/server/agent-chat/prompt-resources.d.ts +4 -1
  76. package/dist/server/agent-chat/prompt-resources.js +8 -2
  77. package/dist/server/agent-chat-plugin.js +148 -100
  78. package/dist/server/index.d.ts +1 -0
  79. package/dist/server/index.js +1 -0
  80. package/dist/server/interval-job.d.ts +25 -0
  81. package/dist/server/interval-job.js +19 -0
  82. package/dist/server/prompts/framework-core-compact.d.ts +5 -0
  83. package/dist/server/prompts/framework-core-compact.js +28 -12
  84. package/dist/server/prompts/framework-core.d.ts +5 -0
  85. package/dist/server/prompts/framework-core.js +33 -16
  86. package/dist/server/prompts/index.d.ts +1 -1
  87. package/dist/server/prompts/index.js +1 -1
  88. package/dist/server/prompts/shared-rules.d.ts +12 -4
  89. package/dist/server/prompts/shared-rules.js +14 -4
  90. package/dist/server/realtime-token.d.ts +1 -1
  91. package/dist/shared/index.d.ts +1 -0
  92. package/dist/shared/index.js +1 -0
  93. package/dist/shared/poll-engine.d.ts +49 -0
  94. package/dist/shared/poll-engine.js +148 -0
  95. package/dist/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  96. package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
  97. package/dist/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
  98. package/dist/tracking/providers.js +10 -1
  99. package/docs/content/deployment.mdx +47 -13
  100. package/docs/content/key-concepts.mdx +13 -6
  101. package/package.json +2 -1
  102. package/src/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  103. package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
  104. package/src/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
@@ -1,11 +1,27 @@
1
+ import { createPollEngine } from "../shared/poll-engine.js";
1
2
  import { agentNativePath } from "./api-path.js";
2
3
  import { announceAgentNativeFrameReady, defaultAgentNativeHostCommands, requestAgentNativeHostActions, requestAgentNativeHostContext, runAgentNativeHostAction, sendAgentNativeHostCommand, } from "./host-bridge.js";
3
4
  const DEFAULT_ENDPOINT = "/_agent-native/browser-sessions";
4
5
  const DEFAULT_HEARTBEAT_MS = 5_000;
5
6
  const DEFAULT_POLL_MS = 500;
7
+ const REQUEST_ABORT_MIN_MS = 10_000;
8
+ // Relaxed cadence floor applied while the tab is hidden — mirrors
9
+ // use-poll-loop.ts's `pauseWhenHidden: false` default so a backgrounded tab
10
+ // keeps heartbeating (the server-side session TTL still needs refreshing)
11
+ // instead of going fully silent.
12
+ const HIDDEN_INTERVAL_FLOOR_MS = 10_000;
13
+ function isDocumentHidden() {
14
+ return (typeof document !== "undefined" && document.visibilityState === "hidden");
15
+ }
6
16
  function browserSessionId() {
7
17
  return `browser-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
8
18
  }
19
+ // Bounds a single request so a hung fetch can't leave the caller waiting
20
+ // forever on a wedged connection.
21
+ function requestAbortMs(options) {
22
+ const cadence = Math.min(options.pollMs ?? DEFAULT_POLL_MS, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
23
+ return Math.max(REQUEST_ABORT_MIN_MS, cadence * 4);
24
+ }
9
25
  function messageError(error) {
10
26
  return error instanceof Error ? error : new Error(String(error));
11
27
  }
@@ -35,26 +51,48 @@ async function readJsonResponse(response) {
35
51
  return body;
36
52
  }
37
53
  async function postJson(options, path, body) {
38
- const response = await fetchImpl(options)(endpointPath(options, path), {
39
- method: "POST",
40
- credentials: "include",
41
- headers: {
42
- "Content-Type": "application/json",
43
- "X-Agent-Native-CSRF": "1",
44
- },
45
- body: JSON.stringify(body ?? {}),
46
- });
47
- return readJsonResponse(response);
54
+ const controller = typeof AbortController === "undefined" ? null : new AbortController();
55
+ const timeoutId = controller
56
+ ? setTimeout(() => controller.abort(), requestAbortMs(options))
57
+ : null;
58
+ try {
59
+ const response = await fetchImpl(options)(endpointPath(options, path), {
60
+ method: "POST",
61
+ credentials: "include",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ "X-Agent-Native-CSRF": "1",
65
+ },
66
+ body: JSON.stringify(body ?? {}),
67
+ ...(controller ? { signal: controller.signal } : {}),
68
+ });
69
+ return readJsonResponse(response);
70
+ }
71
+ finally {
72
+ if (timeoutId)
73
+ clearTimeout(timeoutId);
74
+ }
48
75
  }
49
76
  async function deleteJson(options, path) {
50
- const response = await fetchImpl(options)(endpointPath(options, path), {
51
- method: "DELETE",
52
- credentials: "include",
53
- headers: {
54
- "X-Agent-Native-CSRF": "1",
55
- },
56
- });
57
- await readJsonResponse(response);
77
+ const controller = typeof AbortController === "undefined" ? null : new AbortController();
78
+ const timeoutId = controller
79
+ ? setTimeout(() => controller.abort(), requestAbortMs(options))
80
+ : null;
81
+ try {
82
+ const response = await fetchImpl(options)(endpointPath(options, path), {
83
+ method: "DELETE",
84
+ credentials: "include",
85
+ headers: {
86
+ "X-Agent-Native-CSRF": "1",
87
+ },
88
+ ...(controller ? { signal: controller.signal } : {}),
89
+ });
90
+ await readJsonResponse(response);
91
+ }
92
+ finally {
93
+ if (timeoutId)
94
+ clearTimeout(timeoutId);
95
+ }
58
96
  }
59
97
  function hostRequestOptions(options) {
60
98
  const { endpoint: _endpoint, sessionId: _sessionId, session: _session, getContext: _getContext, actions: _actions, commands: _commands, origin: _origin, label: _label, heartbeatMs: _heartbeatMs, pollMs: _pollMs, ttlMs: _ttlMs, fetch: _fetch, ...hostOptions } = options;
@@ -224,10 +262,7 @@ export function createAgentNativeBrowserSessionBridge(options = {}) {
224
262
  let currentSessionId = options.sessionId ?? null;
225
263
  let fallbackSessionId = null;
226
264
  let started = false;
227
- let heartbeatTimer;
228
- let pollTimer;
229
- let refreshing = false;
230
- let polling = false;
265
+ let onVisibility;
231
266
  async function refreshRegistration() {
232
267
  const direct = hasDirectHost(options);
233
268
  const hostOptions = hostRequestOptions(options);
@@ -256,17 +291,6 @@ export function createAgentNativeBrowserSessionBridge(options = {}) {
256
291
  });
257
292
  return body.session;
258
293
  }
259
- async function heartbeat() {
260
- if (refreshing)
261
- return;
262
- refreshing = true;
263
- try {
264
- await refreshRegistration();
265
- }
266
- finally {
267
- refreshing = false;
268
- }
269
- }
270
294
  async function claimOnce() {
271
295
  if (!currentSessionId) {
272
296
  await refreshRegistration();
@@ -286,17 +310,27 @@ export function createAgentNativeBrowserSessionBridge(options = {}) {
286
310
  }
287
311
  return request;
288
312
  }
289
- async function poll() {
290
- if (polling)
291
- return;
292
- polling = true;
293
- try {
294
- await claimOnce();
295
- }
296
- finally {
297
- polling = false;
298
- }
299
- }
313
+ // This is a plain factory function, not a React component, so it can't use
314
+ // the usePollLoop hook — createPollEngine gives each loop the same
315
+ // never-overlaps/never-stalls guarantees imperatively. The relaxed hidden
316
+ // cadence + visibilitychange wiring below mirrors what use-poll-loop.ts
317
+ // does internally for `pauseWhenHidden: false`.
318
+ const heartbeatEngine = createPollEngine(() => refreshRegistration().then(() => { }), {
319
+ intervalMs: () => {
320
+ const base = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
321
+ return isDocumentHidden()
322
+ ? Math.max(base, HIDDEN_INTERVAL_FLOOR_MS)
323
+ : base;
324
+ },
325
+ });
326
+ const pollEngine = createPollEngine(() => claimOnce().then(() => { }), {
327
+ intervalMs: () => {
328
+ const base = options.pollMs ?? DEFAULT_POLL_MS;
329
+ return isDocumentHidden()
330
+ ? Math.max(base, HIDDEN_INTERVAL_FLOOR_MS)
331
+ : base;
332
+ },
333
+ });
300
334
  const bridge = {
301
335
  get sessionId() {
302
336
  return currentSessionId;
@@ -308,22 +342,31 @@ export function createAgentNativeBrowserSessionBridge(options = {}) {
308
342
  if (!hasDirectHost(options)) {
309
343
  announceAgentNativeFrameReady(hostRequestOptions(options));
310
344
  }
311
- void heartbeat();
312
- void poll();
313
- heartbeatTimer = setInterval(() => void heartbeat(), options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
314
- pollTimer = setInterval(() => void poll(), options.pollMs ?? DEFAULT_POLL_MS);
345
+ heartbeatEngine.start();
346
+ pollEngine.start();
347
+ onVisibility = () => {
348
+ if (isDocumentHidden()) {
349
+ heartbeatEngine.reschedule();
350
+ pollEngine.reschedule();
351
+ }
352
+ else {
353
+ heartbeatEngine.pollNow();
354
+ pollEngine.pollNow();
355
+ }
356
+ };
357
+ document.addEventListener("visibilitychange", onVisibility);
315
358
  return bridge;
316
359
  },
317
360
  stop() {
318
361
  if (!started)
319
362
  return;
320
363
  started = false;
321
- if (heartbeatTimer)
322
- clearInterval(heartbeatTimer);
323
- if (pollTimer)
324
- clearInterval(pollTimer);
325
- heartbeatTimer = undefined;
326
- pollTimer = undefined;
364
+ heartbeatEngine.stop();
365
+ pollEngine.stop();
366
+ if (onVisibility) {
367
+ document.removeEventListener("visibilitychange", onVisibility);
368
+ onVisibility = undefined;
369
+ }
327
370
  if (currentSessionId) {
328
371
  void deleteJson(options, `/${encodePathSegment(currentSessionId)}`).catch(() => { });
329
372
  }
@@ -16,6 +16,7 @@ import { useEffect, useRef } from "react";
16
16
  import { removeAgentChatContextItem, setAgentChatContextItem, } from "../agent-chat.js";
17
17
  import { agentNativePath } from "../api-path.js";
18
18
  import { deleteClientAppState, readClientAppState, setClientAppState, } from "../application-state.js";
19
+ import { usePollLoop } from "../use-poll-loop.js";
19
20
  const NAVIGATION_PATH = agentNativePath("/_agent-native/application-state/navigation");
20
21
  const NAVIGATE_PATH = agentNativePath("/_agent-native/application-state/navigate");
21
22
  const POLL_INTERVAL_MS = 1500;
@@ -137,50 +138,40 @@ export function useDbAdminAgentSync({ table, mode, enabled = true, }) {
137
138
  export function useNavigateConsumer(onNavigate, enabled = true) {
138
139
  const handlerRef = useRef(onNavigate);
139
140
  handlerRef.current = onNavigate;
141
+ const mountedRef = useRef(true);
140
142
  useEffect(() => {
141
- if (!enabled)
142
- return;
143
- let active = true;
144
- let timer;
145
- const tick = async () => {
146
- if (!active)
147
- return;
148
- try {
149
- const res = await fetch(NAVIGATE_PATH, {
150
- method: "GET",
151
- credentials: "include",
152
- headers: headers(),
153
- });
154
- if (active && res.ok) {
155
- const data = (await res.json());
156
- if (data &&
157
- data.view === "database" &&
158
- typeof data.table === "string" &&
159
- data.table) {
160
- const target = data.table;
161
- // Clear the one-shot command before acting so it fires once.
162
- fetch(NAVIGATE_PATH, {
163
- method: "DELETE",
164
- credentials: "include",
165
- headers: headers({ "X-Agent-Native-CSRF": "1" }),
166
- }).catch(() => { });
167
- handlerRef.current(target);
168
- }
169
- }
170
- }
171
- catch {
172
- // Ignore transient errors; the next tick retries.
173
- }
174
- finally {
175
- if (active)
176
- timer = setTimeout(tick, POLL_INTERVAL_MS);
177
- }
178
- };
179
- timer = setTimeout(tick, POLL_INTERVAL_MS);
143
+ mountedRef.current = true;
180
144
  return () => {
181
- active = false;
182
- if (timer)
183
- clearTimeout(timer);
145
+ mountedRef.current = false;
184
146
  };
185
- }, [enabled]);
147
+ }, []);
148
+ usePollLoop(async (signal) => {
149
+ const res = await fetch(NAVIGATE_PATH, {
150
+ method: "GET",
151
+ credentials: "include",
152
+ headers: headers(),
153
+ signal,
154
+ });
155
+ if (!mountedRef.current || !res.ok)
156
+ return;
157
+ const data = (await res.json());
158
+ if (data &&
159
+ data.view === "database" &&
160
+ typeof data.table === "string" &&
161
+ data.table) {
162
+ const target = data.table;
163
+ // Clear the one-shot command before acting so it fires once.
164
+ fetch(NAVIGATE_PATH, {
165
+ method: "DELETE",
166
+ credentials: "include",
167
+ headers: headers({ "X-Agent-Native-CSRF": "1" }),
168
+ }).catch(() => { });
169
+ handlerRef.current(target);
170
+ }
171
+ }, {
172
+ intervalMs: POLL_INTERVAL_MS,
173
+ leading: false,
174
+ pauseWhenHidden: true,
175
+ enabled,
176
+ });
186
177
  }
@@ -15,6 +15,8 @@ export interface AgentDynamicSuggestionsConfig {
15
15
  includeStatic?: boolean;
16
16
  /** Optional app-specific deterministic suggestion builder. */
17
17
  getSuggestions?: (context: AgentDynamicSuggestionContext) => string[];
18
+ /** Safety-net refresh cadence in ms, for updates that don't bump app-state version. Default 30_000. */
19
+ pollMs?: number;
18
20
  }
19
21
  export type AgentDynamicSuggestionsOption = boolean | AgentDynamicSuggestionsConfig;
20
22
  export interface AgentDynamicSuggestionsResult {
@@ -33,6 +35,7 @@ interface NormalizedAgentDynamicSuggestionsConfig {
33
35
  max: number;
34
36
  includeStatic: boolean;
35
37
  getSuggestions?: (context: AgentDynamicSuggestionContext) => string[];
38
+ pollMs: number;
36
39
  }
37
40
  export declare function normalizeAgentDynamicSuggestionsConfig(option?: AgentDynamicSuggestionsOption): NormalizedAgentDynamicSuggestionsConfig;
38
41
  export declare function buildDynamicAgentSuggestions(context: AgentDynamicSuggestionContext): string[];
@@ -1,14 +1,17 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { readClientAppState } from "./application-state.js";
3
3
  import { useChangeVersions } from "./use-change-version.js";
4
+ import { usePollLoop } from "./use-poll-loop.js";
4
5
  const SAFE_BROWSER_TAB_ID_RE = /^[A-Za-z0-9_-]{1,96}$/;
5
6
  const DEFAULT_MAX_SUGGESTIONS = 3;
7
+ const DEFAULT_POLL_MS = 30_000;
6
8
  export function normalizeAgentDynamicSuggestionsConfig(option) {
7
9
  if (option === false) {
8
10
  return {
9
11
  enabled: false,
10
12
  max: DEFAULT_MAX_SUGGESTIONS,
11
13
  includeStatic: true,
14
+ pollMs: DEFAULT_POLL_MS,
12
15
  };
13
16
  }
14
17
  if (option === true || option === undefined) {
@@ -16,6 +19,7 @@ export function normalizeAgentDynamicSuggestionsConfig(option) {
16
19
  enabled: true,
17
20
  max: DEFAULT_MAX_SUGGESTIONS,
18
21
  includeStatic: true,
22
+ pollMs: DEFAULT_POLL_MS,
19
23
  };
20
24
  }
21
25
  return {
@@ -25,6 +29,9 @@ export function normalizeAgentDynamicSuggestionsConfig(option) {
25
29
  : DEFAULT_MAX_SUGGESTIONS,
26
30
  includeStatic: option.includeStatic !== false,
27
31
  ...(option.getSuggestions ? { getSuggestions: option.getSuggestions } : {}),
32
+ pollMs: typeof option.pollMs === "number" && Number.isFinite(option.pollMs)
33
+ ? Math.max(0, Math.floor(option.pollMs))
34
+ : DEFAULT_POLL_MS,
28
35
  };
29
36
  }
30
37
  function normalizeBrowserTabId(browserTabId) {
@@ -263,64 +270,59 @@ export function useAgentDynamicSuggestionsResult(options) {
263
270
  const enabled = options.enabled !== false && config.enabled;
264
271
  const [context, setContext] = useState(null);
265
272
  const [isLoading, setIsLoading] = useState(false);
273
+ // Invalidates in-flight loads from a stale scope/browserTabId/enabled
274
+ // generation — shared between the leading load below and the safety-net
275
+ // poll so a slow response from an old generation can't clobber a newer one.
276
+ const loadGenerationRef = useRef(0);
277
+ const load = useCallback(async (showLoading) => {
278
+ const generation = loadGenerationRef.current;
279
+ if (showLoading)
280
+ setIsLoading(true);
281
+ try {
282
+ const [navigation, selection, pendingSelection, url] = await Promise.all([
283
+ readScopedAppState("navigation", browserTabId),
284
+ readScopedAppState("selection", browserTabId),
285
+ readScopedAppState("pending-selection-context", browserTabId),
286
+ readScopedAppState("__url__", browserTabId),
287
+ ]);
288
+ if (generation !== loadGenerationRef.current)
289
+ return;
290
+ setContext({ navigation, selection, pendingSelection, url, scope });
291
+ }
292
+ catch {
293
+ if (generation !== loadGenerationRef.current)
294
+ return;
295
+ setContext({
296
+ navigation: null,
297
+ selection: null,
298
+ pendingSelection: null,
299
+ url: null,
300
+ scope,
301
+ });
302
+ }
303
+ finally {
304
+ if (generation === loadGenerationRef.current && showLoading) {
305
+ setIsLoading(false);
306
+ }
307
+ }
308
+ }, [browserTabId, scope]);
266
309
  useEffect(() => {
310
+ loadGenerationRef.current += 1;
267
311
  if (!enabled) {
268
312
  setContext(null);
269
313
  setIsLoading(false);
270
314
  return;
271
315
  }
272
- let cancelled = false;
273
- const load = async (showLoading) => {
274
- if (showLoading)
275
- setIsLoading(true);
276
- try {
277
- const [navigation, selection, pendingSelection, url] = await Promise.all([
278
- readScopedAppState("navigation", browserTabId),
279
- readScopedAppState("selection", browserTabId),
280
- readScopedAppState("pending-selection-context", browserTabId),
281
- readScopedAppState("__url__", browserTabId),
282
- ]);
283
- if (cancelled)
284
- return;
285
- setContext({
286
- navigation,
287
- selection,
288
- pendingSelection,
289
- url,
290
- scope,
291
- });
292
- }
293
- catch {
294
- if (cancelled)
295
- return;
296
- setContext({
297
- navigation: null,
298
- selection: null,
299
- pendingSelection: null,
300
- url: null,
301
- scope,
302
- });
303
- }
304
- finally {
305
- if (!cancelled && showLoading)
306
- setIsLoading(false);
307
- }
308
- };
309
316
  void load(true);
310
- const interval = setInterval(() => {
311
- // The useEffect deps already include appStateVersion, so app-state
312
- // changes trigger an immediate event-driven refresh above. This
313
- // interval is only a slow safety net for updates that don't bump
314
- // that version — skip ticks while the tab isn't visible.
315
- if (document.hidden)
316
- return;
317
- void load(false);
318
- }, 30_000);
319
- return () => {
320
- cancelled = true;
321
- clearInterval(interval);
322
- };
323
- }, [appStateVersion, browserTabId, enabled, scope, scopeKey]);
317
+ }, [appStateVersion, enabled, load, scopeKey, config]);
318
+ // Slow safety net for app-state updates that don't bump `appStateVersion`
319
+ // (the effect above already handles the event-driven case). `pollMs <= 0`
320
+ // disables it.
321
+ usePollLoop(() => load(false), {
322
+ intervalMs: config.pollMs,
323
+ leading: false,
324
+ enabled: enabled && config.pollMs > 0,
325
+ });
324
326
  const suggestions = useMemo(() => {
325
327
  if (!enabled) {
326
328
  return options.staticSuggestions
@@ -10,3 +10,4 @@ export { createAgentNativeQueryClient } from "../create-query-client.js";
10
10
  export { AppProviders, type AppProvidersProps } from "../app-providers.js";
11
11
  export { usePinchZoom, type UsePinchZoomOptions } from "../use-pinch-zoom.js";
12
12
  export { useAvatarUrl, uploadAvatar, invalidateAvatarCache, } from "../use-avatar.js";
13
+ export { usePollLoop, type UsePollLoopOptions, type UsePollLoopHandle, } from "../use-poll-loop.js";
@@ -10,3 +10,4 @@ export { createAgentNativeQueryClient } from "../create-query-client.js";
10
10
  export { AppProviders } from "../app-providers.js";
11
11
  export { usePinchZoom } from "../use-pinch-zoom.js";
12
12
  export { useAvatarUrl, uploadAvatar, invalidateAvatarCache, } from "../use-avatar.js";
13
+ export { usePollLoop, } from "../use-poll-loop.js";
@@ -14,6 +14,12 @@ export interface AgentNativeI18nCatalog {
14
14
  sourceLocale?: LocaleCode;
15
15
  messages?: LocaleMessages;
16
16
  loadMessages?: (locale: LocaleCode) => Promise<LocaleMessages | null>;
17
+ /**
18
+ * Locales this app actually ships translations for. Defaults to every
19
+ * framework-supported locale when omitted, which only matches apps whose
20
+ * `loadMessages` covers all of them.
21
+ */
22
+ supportedLocales?: readonly LocaleCode[];
17
23
  }
18
24
  export interface AgentNativeI18nProviderProps {
19
25
  children: React.ReactNode;
@@ -31,6 +37,7 @@ interface LocaleContextValue {
31
37
  metadata: LocaleMetadata;
32
38
  setPreference: (preference: LocalePreference) => Promise<void>;
33
39
  loading: boolean;
40
+ supportedLocales: readonly LocaleCode[];
34
41
  }
35
42
  declare global {
36
43
  var __AGENT_NATIVE_LOCALE_CONTEXT__: React.Context<LocaleContextValue | null> | undefined;
@@ -155,6 +155,7 @@ export function AgentNativeI18nProvider({ children, catalog, initialLocale, init
155
155
  const sourceLocale = catalog?.sourceLocale ?? DEFAULT_LOCALE;
156
156
  const sourceMessages = catalog?.messages ?? {};
157
157
  const loadMessages = catalog?.loadMessages;
158
+ const supportedLocales = catalog?.supportedLocales ?? SUPPORTED_LOCALES;
158
159
  const hydration = readHydrationPayload();
159
160
  const initialState = useMemo(() => resolveInitialState({ initialLocale, initialPreference }), [initialLocale, initialPreference]);
160
161
  const [preference, setPreferenceState] = useState(initialState.preference);
@@ -305,7 +306,15 @@ export function AgentNativeI18nProvider({ children, catalog, initialLocale, init
305
306
  metadata: LOCALE_METADATA[locale],
306
307
  setPreference,
307
308
  loading,
308
- }), [loading, locale, preference, setPreference, sourceLocale]);
309
+ supportedLocales,
310
+ }), [
311
+ loading,
312
+ locale,
313
+ preference,
314
+ setPreference,
315
+ sourceLocale,
316
+ supportedLocales,
317
+ ]);
309
318
  return (_jsx(LocaleContext.Provider, { value: context, children: _jsx(I18nextProvider, { i18n: i18n, children: children }) }));
310
319
  }
311
320
  export function useLocale() {
@@ -472,7 +481,7 @@ export function useFormatters() {
472
481
  }), [locale]);
473
482
  }
474
483
  export function LanguagePicker({ className, includeSystem = true, label, variant = "select", }) {
475
- const { locale, preference, setPreference } = useLocale();
484
+ const { locale, preference, setPreference, supportedLocales } = useLocale();
476
485
  const [open, setOpen] = useState(false);
477
486
  const copy = LANGUAGE_PICKER_COPY[locale] ?? LANGUAGE_PICKER_COPY[DEFAULT_LOCALE];
478
487
  const resolvedLabel = label ?? copy.label;
@@ -486,7 +495,7 @@ export function LanguagePicker({ className, includeSystem = true, label, variant
486
495
  },
487
496
  ]
488
497
  : []),
489
- ...SUPPORTED_LOCALES.map((code) => ({
498
+ ...supportedLocales.map((code) => ({
490
499
  value: code,
491
500
  label: `${LOCALE_METADATA[code].nativeName} (${code})`,
492
501
  description: code,
@@ -10,5 +10,5 @@ export interface IntegrationStatus {
10
10
  export declare function useIntegrationStatus(): {
11
11
  statuses: IntegrationStatus[];
12
12
  loading: boolean;
13
- refetch: () => Promise<void>;
13
+ refetch: (signal?: AbortSignal) => Promise<void>;
14
14
  };
@@ -1,37 +1,24 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { useState, useCallback } from "react";
2
2
  import { agentNativePath } from "../api-path.js";
3
+ import { usePollLoop } from "../use-poll-loop.js";
3
4
  export function useIntegrationStatus() {
4
5
  const [statuses, setStatuses] = useState([]);
5
6
  const [loading, setLoading] = useState(true);
6
- const mountedRef = useRef(true);
7
- const fetchStatuses = useCallback(async () => {
7
+ const fetchStatuses = useCallback(async (signal) => {
8
8
  try {
9
- const res = await fetch(agentNativePath("/_agent-native/integrations/status"));
9
+ const res = await fetch(agentNativePath("/_agent-native/integrations/status"), { signal });
10
10
  if (!res.ok) {
11
- if (mountedRef.current)
12
- setLoading(false);
11
+ setLoading(false);
13
12
  return;
14
13
  }
15
14
  const data = await res.json();
16
- if (mountedRef.current) {
17
- setStatuses(Array.isArray(data) ? data : []);
18
- setLoading(false);
19
- }
15
+ setStatuses(Array.isArray(data) ? data : []);
16
+ setLoading(false);
20
17
  }
21
18
  catch {
22
- if (mountedRef.current) {
23
- setLoading(false);
24
- }
19
+ setLoading(false);
25
20
  }
26
21
  }, []);
27
- useEffect(() => {
28
- mountedRef.current = true;
29
- fetchStatuses();
30
- const interval = setInterval(fetchStatuses, 30000);
31
- return () => {
32
- mountedRef.current = false;
33
- clearInterval(interval);
34
- };
35
- }, [fetchStatuses]);
22
+ usePollLoop(fetchStatuses, { intervalMs: 30_000 });
36
23
  return { statuses, loading, refetch: fetchStatuses };
37
24
  }
@@ -3,7 +3,7 @@ import { IconBell, IconBellRinging, IconLoader2, IconX, } from "@tabler/icons-re
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { agentNativePath, appPath } from "../api-path.js";
5
5
  import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js";
6
- import { usePausingInterval } from "../use-pausing-interval.js";
6
+ import { usePollLoop } from "../use-poll-loop.js";
7
7
  const POLL_MS_DEFAULT = 10_000;
8
8
  const SUPPORTS_NOTIFICATION = typeof window !== "undefined" && "Notification" in window;
9
9
  /**
@@ -47,10 +47,10 @@ export function NotificationsBell({ pollMs = POLL_MS_DEFAULT, className, browser
47
47
  // the popup loop — no second /count request), and pop Notification() for
48
48
  // any new ids. When off, we fetch just /count. The unread-list branch also
49
49
  // opts out of visibility pause so popups still fire for backgrounded tabs.
50
- const refresh = useCallback(async () => {
50
+ const refresh = useCallback(async (signal) => {
51
51
  if (browserNotifications) {
52
52
  try {
53
- const res = await fetch(agentNativePath("/_agent-native/notifications?unread=true&limit=20"));
53
+ const res = await fetch(agentNativePath("/_agent-native/notifications?unread=true&limit=20"), { signal });
54
54
  if (!res.ok)
55
55
  return;
56
56
  const rows = (await res.json());
@@ -74,19 +74,21 @@ export function NotificationsBell({ pollMs = POLL_MS_DEFAULT, className, browser
74
74
  new Notification(n.title, { body: n.body, tag: n.id });
75
75
  }
76
76
  catch {
77
- // Safari / restricted contexts may throw even when permission
78
- // claims to be granted — silent no-op.
77
+ // coercion-ok: Safari / restricted contexts may throw even when
78
+ // permission claims to be granted; one failed OS notification
79
+ // must not abort the unread sweep around it.
79
80
  }
80
81
  }
81
82
  seenIdsRef.current = seen;
82
83
  }
83
84
  catch {
84
- // best-effort
85
+ // coercion-ok: the bell renders from the rows it already has; a
86
+ // failed sweep is retried by the next poll tick.
85
87
  }
86
88
  return;
87
89
  }
88
90
  try {
89
- const res = await fetch(agentNativePath("/_agent-native/notifications/count"));
91
+ const res = await fetch(agentNativePath("/_agent-native/notifications/count"), { signal });
90
92
  if (!res.ok)
91
93
  return;
92
94
  const data = (await res.json());
@@ -96,8 +98,11 @@ export function NotificationsBell({ pollMs = POLL_MS_DEFAULT, className, browser
96
98
  // best-effort
97
99
  }
98
100
  }, [browserNotifications]);
99
- usePausingInterval(refresh, pollMs,
100
- /* pauseWhenHidden */ !browserNotifications);
101
+ usePollLoop(refresh, {
102
+ intervalMs: pollMs,
103
+ enabled: pollMs > 0,
104
+ pauseWhenHidden: !browserNotifications,
105
+ });
101
106
  useEffect(() => {
102
107
  if (!open)
103
108
  return;