@agent-native/core 0.144.1 → 0.144.3

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 (99) hide show
  1. package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -4
  2. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +7 -3
  3. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +13 -3
  4. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +64 -23
  5. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +69 -46
  6. package/corpus/templates/calendar/app/hooks/use-view-preferences.ts +35 -6
  7. package/corpus/templates/calendar/app/hooks/use-zoom-auth.ts +24 -3
  8. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +45 -6
  9. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +38 -5
  10. package/corpus/templates/clips/desktop/src/app.tsx +146 -45
  11. package/corpus/templates/clips/server/jobs/brain-export.ts +13 -6
  12. package/corpus/templates/clips/server/jobs/builder-media-compression.ts +10 -3
  13. package/corpus/templates/clips/server/jobs/meeting-reminders.ts +10 -3
  14. package/corpus/templates/clips/server/jobs/poll-calendars.ts +23 -21
  15. package/corpus/templates/clips/server/jobs/stale-meeting-sweeper.ts +10 -3
  16. package/corpus/templates/clips/server/jobs/transactional-emails.ts +10 -3
  17. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +1 -0
  18. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +15 -2
  19. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +82 -39
  20. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +31 -9
  21. package/corpus/templates/mail/server/plugins/mail-jobs.ts +36 -17
  22. package/dist/client/AssistantChat.js +73 -43
  23. package/dist/client/MultiTabAssistantChat.d.ts +3 -1
  24. package/dist/client/MultiTabAssistantChat.js +79 -92
  25. package/dist/client/analytics/index.d.ts +1 -1
  26. package/dist/client/analytics/index.js +1 -1
  27. package/dist/client/analytics-session.d.ts +13 -0
  28. package/dist/client/analytics-session.js +57 -0
  29. package/dist/client/analytics.d.ts +1 -0
  30. package/dist/client/analytics.js +1 -0
  31. package/dist/client/browser-session-bridge.js +97 -54
  32. package/dist/client/db-admin/useAgentSync.js +34 -43
  33. package/dist/client/dynamic-suggestions.d.ts +3 -0
  34. package/dist/client/dynamic-suggestions.js +54 -52
  35. package/dist/client/hooks/index.d.ts +1 -0
  36. package/dist/client/hooks/index.js +1 -0
  37. package/dist/client/i18n.d.ts +7 -0
  38. package/dist/client/i18n.js +12 -3
  39. package/dist/client/index.d.ts +1 -1
  40. package/dist/client/index.js +1 -1
  41. package/dist/client/integrations/useIntegrationStatus.d.ts +1 -1
  42. package/dist/client/integrations/useIntegrationStatus.js +9 -22
  43. package/dist/client/notifications/NotificationsBell.js +14 -9
  44. package/dist/client/progress/RunsTray.js +6 -5
  45. package/dist/client/settings/useBuilderStatus.js +95 -73
  46. package/dist/client/track.js +7 -0
  47. package/dist/client/transcription/BuilderTranscriptionCta.js +27 -31
  48. package/dist/client/use-action.js +10 -1
  49. package/dist/client/use-agent-engine-configured.js +21 -3
  50. package/dist/client/use-pausing-interval.d.ts +6 -0
  51. package/dist/client/use-pausing-interval.js +6 -0
  52. package/dist/client/use-poll-loop.d.ts +39 -0
  53. package/dist/client/use-poll-loop.js +73 -0
  54. package/dist/client/use-run-stuck-detection.js +41 -13
  55. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  56. package/dist/integrations/google-docs-poller.js +26 -7
  57. package/dist/integrations/pending-tasks-retry-job.js +17 -23
  58. package/dist/integrations/plugin.js +16 -15
  59. package/dist/integrations/remote-push-delivery-job.js +16 -25
  60. package/dist/integrations/remote-retry-job.js +19 -23
  61. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  62. package/dist/notifications/routes.d.ts +3 -3
  63. package/dist/oauth-tokens/google-refresh.js +12 -19
  64. package/dist/observability/routes.d.ts +3 -3
  65. package/dist/progress/routes.d.ts +1 -1
  66. package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
  67. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  68. package/dist/resources/handlers.d.ts +1 -1
  69. package/dist/secrets/routes.d.ts +3 -3
  70. package/dist/server/action-routes.js +4 -2
  71. package/dist/server/agent-chat-plugin.js +26 -29
  72. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  73. package/dist/server/agent-run-context.d.ts +4 -2
  74. package/dist/server/agent-run-context.js +5 -3
  75. package/dist/server/core-routes-plugin.js +2 -0
  76. package/dist/server/index.d.ts +1 -0
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/interval-job.d.ts +25 -0
  79. package/dist/server/interval-job.js +19 -0
  80. package/dist/shared/index.d.ts +1 -0
  81. package/dist/shared/index.js +1 -0
  82. package/dist/shared/poll-engine.d.ts +49 -0
  83. package/dist/shared/poll-engine.js +148 -0
  84. package/dist/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  85. package/dist/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
  86. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +5 -1
  87. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +20 -3
  88. package/dist/tracking/index.d.ts +1 -1
  89. package/dist/tracking/providers.js +22 -2
  90. package/dist/tracking/registry.d.ts +12 -2
  91. package/dist/tracking/registry.js +27 -3
  92. package/dist/tracking/types.d.ts +8 -0
  93. package/docs/content/observability.mdx +16 -0
  94. package/docs/content/tracking.mdx +17 -2
  95. package/package.json +4 -3
  96. package/src/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  97. package/src/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
  98. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +5 -1
  99. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +20 -3
@@ -166,10 +166,24 @@ async function testConnection(
166
166
  return res.json();
167
167
  }
168
168
 
169
+ // Bounds the GitHub status fetch so a hang can't leave the connect poll's
170
+ // `inFlight` guard stuck and stall the interval forever.
171
+ const GITHUB_OAUTH_STATUS_ABORT_MS = 10_000;
172
+
169
173
  async function fetchGitHubOAuthStatus(): Promise<GitHubOAuthStatus> {
170
- const res = await fetch(
171
- agentNativePath("/_agent-native/oauth/github/status"),
174
+ const controller = new AbortController();
175
+ const abortTimer = setTimeout(
176
+ () => controller.abort(),
177
+ GITHUB_OAUTH_STATUS_ABORT_MS,
172
178
  );
179
+ let res: Response;
180
+ try {
181
+ res = await fetch(agentNativePath("/_agent-native/oauth/github/status"), {
182
+ signal: controller.signal,
183
+ });
184
+ } finally {
185
+ clearTimeout(abortTimer);
186
+ }
173
187
  if (!res.ok) {
174
188
  const data = await res.json().catch(() => ({}));
175
189
  throw new Error(data.error || "Failed to load GitHub status");
@@ -374,8 +388,18 @@ function GitHubOAuthView({
374
388
  },
375
389
  onSuccess: () => {
376
390
  const startedAt = Date.now();
377
- const pollId = window.setInterval(() => {
378
- refresh();
391
+ let inFlight = false;
392
+ const pollId = window.setInterval(async () => {
393
+ if (document.hidden || inFlight) return;
394
+ inFlight = true;
395
+ try {
396
+ await queryClient.invalidateQueries({
397
+ queryKey: ["github-oauth-status"],
398
+ });
399
+ onSaved();
400
+ } finally {
401
+ inFlight = false;
402
+ }
379
403
  if (Date.now() - startedAt > 120_000) {
380
404
  window.clearInterval(pollId);
381
405
  }
@@ -15,6 +15,11 @@ declare global {
15
15
 
16
16
  let running = false;
17
17
  const DEFAULT_MAX_REPORTS_PER_SWEEP = 5;
18
+ // Bounds snapshot/panel/render work per subscription. Originally only applied
19
+ // in serverless mode to fit the function's execution limit; it now also
20
+ // backstops the long-running in-process cron, where a hung query or render
21
+ // call would otherwise leave `running` stuck forever instead of just
22
+ // delaying the next sweep.
18
23
  const SERVERLESS_REPORT_DELIVERY_BUDGET_MS = 220_000;
19
24
  const SERVERLESS_MAX_REPORTS_PER_SWEEP = 1;
20
25
 
@@ -131,9 +136,8 @@ export async function runDashboardReportsOnce(): Promise<{
131
136
  remaining = batch.length >= sweepLimit ? 1 : 0;
132
137
  for (const sub of batch) {
133
138
  processed++;
134
- const deliveryDeadlineAt = serverlessDashboardReportRuntime()
135
- ? Date.now() + SERVERLESS_REPORT_DELIVERY_BUDGET_MS
136
- : undefined;
139
+ const deliveryDeadlineAt =
140
+ Date.now() + SERVERLESS_REPORT_DELIVERY_BUDGET_MS;
137
141
  const retryAt = dashboardReportRetryAt(sub);
138
142
  try {
139
143
  const result = await runWithRequestContext(
@@ -1,7 +1,7 @@
1
1
  import { runDashboardReportsOnce } from "../jobs/dashboard-report";
2
2
  import { isProductionServerlessRuntime } from "../lib/production-serverless-runtime";
3
3
 
4
- const INTERVAL_MS = 60_000;
4
+ const DEFAULT_INTERVAL_MS = 60_000;
5
5
  let skippingLogged = false;
6
6
 
7
7
  declare global {
@@ -15,6 +15,15 @@ function platformSchedulerOwnsReports(): boolean {
15
15
  );
16
16
  }
17
17
 
18
+ function intervalMs(): number {
19
+ const raw = process.env.DASHBOARD_REPORT_INTERVAL_MS?.trim();
20
+ if (!raw) return DEFAULT_INTERVAL_MS;
21
+ const parsed = Number.parseInt(raw, 10);
22
+ return Number.isFinite(parsed) && parsed >= 10_000
23
+ ? parsed
24
+ : DEFAULT_INTERVAL_MS;
25
+ }
26
+
18
27
  export default function registerDashboardReportJobs(): void {
19
28
  const isProd = process.env.NODE_ENV === "production";
20
29
  const flag =
@@ -36,13 +45,14 @@ export default function registerDashboardReportJobs(): void {
36
45
  return;
37
46
  }
38
47
 
48
+ const ms = intervalMs();
39
49
  setInterval(() => {
40
50
  runDashboardReportsOnce().catch((err) =>
41
51
  console.error("[dashboard-report] interval failed:", err),
42
52
  );
43
- }, INTERVAL_MS);
53
+ }, ms);
44
54
 
45
55
  console.log(
46
- `[dashboard-report] Recurring dashboard report sweep every ${INTERVAL_MS / 1000}s.`,
56
+ `[dashboard-report] Recurring dashboard report sweep every ${ms / 1000}s.`,
47
57
  );
48
58
  }
@@ -73,6 +73,11 @@ function getSetupSteps(t: CalendarT) {
73
73
  ];
74
74
  }
75
75
 
76
+ const STATUS_POLL_INTERVAL_MS = 2000;
77
+ // Bounds each status poll so a hung fetch can't leave the in-flight guard
78
+ // permanently stuck and stall the interval forever.
79
+ const STATUS_POLL_ABORT_MS = Math.max(10_000, STATUS_POLL_INTERVAL_MS * 4);
80
+
76
81
  interface GoogleConnectBannerProps {
77
82
  variant?: "banner" | "hero";
78
83
  }
@@ -99,6 +104,8 @@ export function GoogleConnectBanner({
99
104
  const isBuilderFrame = useMemo(() => isInBuilderFrame(), []);
100
105
  const authPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
101
106
  const addAccountPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
107
+ const authPollInFlightRef = useRef(false);
108
+ const addAccountPollInFlightRef = useRef(false);
102
109
  const {
103
110
  isDesktopGoogleAuth,
104
111
  isGoogleDesktopAuthPending,
@@ -165,21 +172,38 @@ export function GoogleConnectBanner({
165
172
 
166
173
  if (authPollRef.current) clearInterval(authPollRef.current);
167
174
  authPollRef.current = setInterval(async () => {
168
- const res = await fetch(
169
- agentNativePath("/_agent-native/google/status"),
170
- ).catch(() => null);
171
- if (res?.ok) {
172
- const data = await res.json();
173
- if (data.connected) {
174
- if (authPollRef.current) {
175
- clearInterval(authPollRef.current);
176
- authPollRef.current = null;
175
+ if (document.hidden || authPollInFlightRef.current) return;
176
+ authPollInFlightRef.current = true;
177
+ const controller = new AbortController();
178
+ const abortTimer = setTimeout(
179
+ () => controller.abort(),
180
+ STATUS_POLL_ABORT_MS,
181
+ );
182
+ try {
183
+ const res = await fetch(
184
+ agentNativePath("/_agent-native/google/status"),
185
+ { signal: controller.signal },
186
+ )
187
+ // coercion-ok: a failed probe and a not-yet-connected response both
188
+ // mean "stay on the connect banner"; this loop only ever acts on an
189
+ // observed success, and credential errors surface via authUrl.error.
190
+ .catch(() => null);
191
+ if (res?.ok) {
192
+ const data = await res.json();
193
+ if (data.connected) {
194
+ if (authPollRef.current) {
195
+ clearInterval(authPollRef.current);
196
+ authPollRef.current = null;
197
+ }
198
+ setDismissed(true);
199
+ window.location.reload();
177
200
  }
178
- setDismissed(true);
179
- window.location.reload();
180
201
  }
202
+ } finally {
203
+ clearTimeout(abortTimer);
204
+ authPollInFlightRef.current = false;
181
205
  }
182
- }, 2000);
206
+ }, STATUS_POLL_INTERVAL_MS);
183
207
  }, [wantAuthUrl, authUrl.data, isBuilderFrame]);
184
208
 
185
209
  // When auth URL fails with missing credentials, show wizard
@@ -242,20 +266,37 @@ export function GoogleConnectBanner({
242
266
  const prevCount = accounts.length;
243
267
  if (addAccountPollRef.current) clearInterval(addAccountPollRef.current);
244
268
  addAccountPollRef.current = setInterval(async () => {
245
- const res = await fetch(
246
- agentNativePath("/_agent-native/google/status"),
247
- ).catch(() => null);
248
- if (res?.ok) {
249
- const data = await res.json();
250
- if (data.accounts?.length > prevCount) {
251
- if (addAccountPollRef.current) {
252
- clearInterval(addAccountPollRef.current);
253
- addAccountPollRef.current = null;
269
+ if (document.hidden || addAccountPollInFlightRef.current) return;
270
+ addAccountPollInFlightRef.current = true;
271
+ const controller = new AbortController();
272
+ const abortTimer = setTimeout(
273
+ () => controller.abort(),
274
+ STATUS_POLL_ABORT_MS,
275
+ );
276
+ try {
277
+ const res = await fetch(
278
+ agentNativePath("/_agent-native/google/status"),
279
+ { signal: controller.signal },
280
+ )
281
+ // coercion-ok: a failed probe and a not-yet-connected response both
282
+ // mean "stay on the connect banner"; this loop only ever acts on an
283
+ // observed success, and credential errors surface via authUrl.error.
284
+ .catch(() => null);
285
+ if (res?.ok) {
286
+ const data = await res.json();
287
+ if (data.accounts?.length > prevCount) {
288
+ if (addAccountPollRef.current) {
289
+ clearInterval(addAccountPollRef.current);
290
+ addAccountPollRef.current = null;
291
+ }
292
+ window.location.reload();
254
293
  }
255
- window.location.reload();
256
294
  }
295
+ } finally {
296
+ clearTimeout(abortTimer);
297
+ addAccountPollInFlightRef.current = false;
257
298
  }
258
- }, 2000);
299
+ }, STATUS_POLL_INTERVAL_MS);
259
300
  // accounts.length is captured into prevCount above; including it in deps
260
301
  // would tear down and recreate the interval whenever the count changes.
261
302
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -37,6 +37,14 @@ interface DesktopGlobals {
37
37
  electronAPI?: unknown;
38
38
  }
39
39
 
40
+ const DESKTOP_AUTH_POLL_INTERVAL_MS = 1500;
41
+ // Bounds each poll tick's fetches so a hung request can't leave
42
+ // pollInFlightRef stuck and stall the interval forever.
43
+ const DESKTOP_AUTH_POLL_ABORT_MS = Math.max(
44
+ 10_000,
45
+ DESKTOP_AUTH_POLL_INTERVAL_MS * 4,
46
+ );
47
+
40
48
  function bodyError(
41
49
  body: any,
42
50
  raw: string | undefined,
@@ -188,6 +196,7 @@ export function useGoogleAddAccountUrl(enabled = false) {
188
196
  export function useGoogleDesktopAuth(options: DesktopAuthOptions = {}) {
189
197
  const { onError, onSuccess, timeoutMs = 120_000 } = options;
190
198
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
199
+ const pollInFlightRef = useRef(false);
191
200
  const [isPending, setIsPending] = useState(false);
192
201
  const isDesktopGoogleAuth = useMemo(() => {
193
202
  if (typeof window === "undefined" || typeof navigator === "undefined") {
@@ -285,62 +294,76 @@ export function useGoogleDesktopAuth(options: DesktopAuthOptions = {}) {
285
294
  })();
286
295
 
287
296
  pollRef.current = setInterval(async () => {
297
+ if (document.hidden || pollInFlightRef.current) return;
298
+ pollInFlightRef.current = true;
299
+ const controller = new AbortController();
300
+ const abortTimer = setTimeout(
301
+ () => controller.abort(),
302
+ DESKTOP_AUTH_POLL_ABORT_MS,
303
+ );
288
304
  try {
289
- const exchangeRes = await fetch(
290
- agentNativePath(
291
- `/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
292
- ),
293
- { credentials: "include" },
294
- );
295
- const exchange = await exchangeRes.json();
296
- if (exchange?.error) {
297
- clearPoll();
298
- setIsPending(false);
299
- onError?.(exchange);
300
- return;
301
- }
302
- if (exchange?.token) {
303
- await fetch(
305
+ try {
306
+ const exchangeRes = await fetch(
304
307
  agentNativePath(
305
- `/_agent-native/auth/session?_session=${exchange.token}`,
308
+ `/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
306
309
  ),
307
- { credentials: "include" },
310
+ { credentials: "include", signal: controller.signal },
308
311
  );
309
- await finish({ token: exchange.token, email: exchange.email });
310
- return;
312
+ const exchange = await exchangeRes.json();
313
+ if (exchange?.error) {
314
+ clearPoll();
315
+ setIsPending(false);
316
+ onError?.(exchange);
317
+ return;
318
+ }
319
+ if (exchange?.token) {
320
+ await fetch(
321
+ agentNativePath(
322
+ `/_agent-native/auth/session?_session=${exchange.token}`,
323
+ ),
324
+ { credentials: "include", signal: controller.signal },
325
+ );
326
+ await finish({ token: exchange.token, email: exchange.email });
327
+ return;
328
+ }
329
+ } catch {
330
+ // coercion-ok: keep polling; the status endpoint below may still
331
+ // observe success, and the timeout below reports desktop_auth_timeout.
311
332
  }
312
- } catch {
313
- // Keep polling; the status endpoint below may still observe success.
314
- }
315
333
 
316
- try {
317
- const statusRes = await fetch(
318
- agentNativePath("/_agent-native/google/status"),
319
- { credentials: "include" },
320
- );
321
- if (statusRes.ok) {
322
- const status = (await statusRes.json()) as GoogleAuthStatus;
323
- const connected = startOptions.addAccount
324
- ? (status.accounts?.length ?? 0) >
325
- (startOptions.previousAccountCount ?? 0)
326
- : status.connected;
327
- if (connected) {
328
- await finish();
329
- return;
334
+ try {
335
+ const statusRes = await fetch(
336
+ agentNativePath("/_agent-native/google/status"),
337
+ { credentials: "include", signal: controller.signal },
338
+ );
339
+ if (statusRes.ok) {
340
+ const status = (await statusRes.json()) as GoogleAuthStatus;
341
+ const connected = startOptions.addAccount
342
+ ? (status.accounts?.length ?? 0) >
343
+ (startOptions.previousAccountCount ?? 0)
344
+ : status.connected;
345
+ if (connected) {
346
+ await finish();
347
+ return;
348
+ }
330
349
  }
350
+ } catch {
351
+ // coercion-ok: keep polling until the bounded timeout below, which
352
+ // reports desktop_auth_timeout rather than failing silently.
331
353
  }
332
- } catch {
333
- // Keep polling until the timeout.
334
- }
335
354
 
336
- if (Date.now() - startedAt > timeoutMs) {
337
- reportError({
338
- code: "desktop_auth_timeout",
339
- message:
340
- "Google sign-in timed out. Finish sign-in in the browser or try again.",
341
- });
355
+ if (Date.now() - startedAt > timeoutMs) {
356
+ reportError({
357
+ code: "desktop_auth_timeout",
358
+ message:
359
+ "Google sign-in timed out. Finish sign-in in the browser or try again.",
360
+ });
361
+ }
362
+ } finally {
363
+ clearTimeout(abortTimer);
364
+ pollInFlightRef.current = false;
342
365
  }
343
- }, 1500);
366
+ }, DESKTOP_AUTH_POLL_INTERVAL_MS);
344
367
 
345
368
  return true;
346
369
  },
@@ -104,12 +104,25 @@ function save(prefs: CalendarViewPreferences) {
104
104
  } catch {}
105
105
  }
106
106
 
107
+ const REFRESH_INTERVAL_MS = 2_000;
108
+ // Bounds the refresh fetch so a hung request can't stall the self-rescheduling
109
+ // setTimeout loop forever.
110
+ const REFRESH_ABORT_MS = Math.max(10_000, REFRESH_INTERVAL_MS * 4);
111
+
107
112
  async function readAppStatePreferences(): Promise<CalendarViewPreferences | null> {
108
- const res = await fetch(
109
- agentNativePath(
110
- `/_agent-native/application-state/${CALENDAR_VIEW_PREFERENCES_KEY}`,
111
- ),
112
- );
113
+ const controller = new AbortController();
114
+ const abortTimer = setTimeout(() => controller.abort(), REFRESH_ABORT_MS);
115
+ let res: Response;
116
+ try {
117
+ res = await fetch(
118
+ agentNativePath(
119
+ `/_agent-native/application-state/${CALENDAR_VIEW_PREFERENCES_KEY}`,
120
+ ),
121
+ { signal: controller.signal },
122
+ );
123
+ } finally {
124
+ clearTimeout(abortTimer);
125
+ }
113
126
  if (res.status === 404) return null;
114
127
  if (!res.ok) throw new Error(`${res.status}`);
115
128
  return normalizeCalendarViewPreferences(await res.json());
@@ -141,6 +154,11 @@ export function useViewPreferences() {
141
154
  let timeout: number | undefined;
142
155
 
143
156
  async function refresh() {
157
+ if (document.hidden) {
158
+ if (!cancelled)
159
+ timeout = window.setTimeout(refresh, REFRESH_INTERVAL_MS);
160
+ return;
161
+ }
144
162
  try {
145
163
  const remote = await readAppStatePreferences();
146
164
  if (!cancelled && remote) {
@@ -182,13 +200,24 @@ export function useViewPreferences() {
182
200
  // Preferences are a UI convenience; keep the local copy if app-state
183
201
  // is temporarily unavailable.
184
202
  } finally {
185
- if (!cancelled) timeout = window.setTimeout(refresh, 2_000);
203
+ if (!cancelled)
204
+ timeout = window.setTimeout(refresh, REFRESH_INTERVAL_MS);
205
+ }
206
+ }
207
+
208
+ function handleVisibilityChange() {
209
+ if (!document.hidden && timeout) {
210
+ window.clearTimeout(timeout);
211
+ timeout = undefined;
212
+ void refresh();
186
213
  }
187
214
  }
215
+ document.addEventListener("visibilitychange", handleVisibilityChange);
188
216
 
189
217
  void refresh();
190
218
  return () => {
191
219
  cancelled = true;
220
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
192
221
  if (timeout) window.clearTimeout(timeout);
193
222
  };
194
223
  }, []);
@@ -9,8 +9,22 @@ export interface ZoomAuthStatus {
9
9
  accounts: Array<{ id: string; email?: string; displayName?: string }>;
10
10
  }
11
11
 
12
+ // Bounds every zoom fetch so a hung request can't leave the status poll's
13
+ // `inFlight` guard stuck forever.
14
+ const FETCH_ABORT_MS = 10_000;
15
+
12
16
  async function fetchJson<T>(input: string, init?: RequestInit): Promise<T> {
13
- const res = await fetch(input, init);
17
+ const controller = new AbortController();
18
+ const abortTimer = setTimeout(() => controller.abort(), FETCH_ABORT_MS);
19
+ let res: Response;
20
+ try {
21
+ res = await fetch(input, {
22
+ ...init,
23
+ signal: init?.signal ?? controller.signal,
24
+ });
25
+ } finally {
26
+ clearTimeout(abortTimer);
27
+ }
14
28
  if (!res.ok) {
15
29
  let message = `${input} -> ${res.status}`;
16
30
  try {
@@ -124,8 +138,15 @@ export function useConnectZoom() {
124
138
  queryClient.invalidateQueries({ queryKey: ["zoom-status"] });
125
139
 
126
140
  const startedAt = Date.now();
127
- const pollId = window.setInterval(() => {
128
- queryClient.invalidateQueries({ queryKey: ["zoom-status"] });
141
+ let inFlight = false;
142
+ const pollId = window.setInterval(async () => {
143
+ if (document.hidden || inFlight) return;
144
+ inFlight = true;
145
+ try {
146
+ await queryClient.invalidateQueries({ queryKey: ["zoom-status"] });
147
+ } finally {
148
+ inFlight = false;
149
+ }
129
150
  if (Date.now() - startedAt > 120_000) {
130
151
  window.clearInterval(pollId);
131
152
  }
@@ -594,6 +594,8 @@ function TranscriptSetupCard({
594
594
  const [connectError, setConnectError] = useState<string | null>(null);
595
595
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
596
596
  const mountedRef = useRef(true);
597
+ const inFlightRef = useRef(false);
598
+ const visibilityHandlerRef = useRef<(() => void) | null>(null);
597
599
 
598
600
  const autoRetryRef = useRef(false);
599
601
  const onRetryRef = useRef(onRetry);
@@ -602,6 +604,16 @@ function TranscriptSetupCard({
602
604
  onRetryRef.current = onRetry;
603
605
  }, [onRetry]);
604
606
 
607
+ const stopVisibilityHandler = useCallback(() => {
608
+ if (visibilityHandlerRef.current) {
609
+ document.removeEventListener(
610
+ "visibilitychange",
611
+ visibilityHandlerRef.current,
612
+ );
613
+ visibilityHandlerRef.current = null;
614
+ }
615
+ }, []);
616
+
605
617
  useEffect(() => {
606
618
  mountedRef.current = true;
607
619
  fetch(agentNativePath("/_agent-native/builder/status"))
@@ -617,8 +629,9 @@ function TranscriptSetupCard({
617
629
  return () => {
618
630
  mountedRef.current = false;
619
631
  if (pollRef.current) clearInterval(pollRef.current);
632
+ stopVisibilityHandler();
620
633
  };
621
- }, []);
634
+ }, [stopVisibilityHandler]);
622
635
 
623
636
  useEffect(() => {
624
637
  if (!builderConfigured || autoRetryRef.current) return;
@@ -630,36 +643,62 @@ function TranscriptSetupCard({
630
643
 
631
644
  const handleConnect = useCallback(() => {
632
645
  if (pollRef.current) clearInterval(pollRef.current);
646
+ stopVisibilityHandler();
647
+ inFlightRef.current = false;
633
648
  setConnecting(true);
634
649
  setConnectError(null);
635
650
 
636
651
  openBuilderConnectPopup({ source: "clips_transcript_panel" });
637
652
 
638
653
  const start = Date.now();
639
- pollRef.current = setInterval(async () => {
654
+ const tick = async () => {
655
+ if (document.hidden || inFlightRef.current) return;
656
+ inFlightRef.current = true;
657
+ const controller = new AbortController();
658
+ const abortTimer = setTimeout(
659
+ () => controller.abort(),
660
+ Math.max(10_000, 2000 * 4),
661
+ );
640
662
  try {
641
- const r = await fetch(agentNativePath("/_agent-native/builder/status"));
663
+ const r = await fetch(
664
+ agentNativePath("/_agent-native/builder/status"),
665
+ {
666
+ signal: controller.signal,
667
+ },
668
+ );
642
669
  if (!r.ok) return;
643
670
  const s = (await r.json()) as { configured: boolean };
644
671
  if (!mountedRef.current) {
645
672
  clearInterval(pollRef.current!);
673
+ stopVisibilityHandler();
646
674
  return;
647
675
  }
648
676
  if (s.configured) {
649
677
  clearInterval(pollRef.current!);
678
+ stopVisibilityHandler();
650
679
  setBuilderConfigured(true);
651
680
  setConnecting(false);
652
681
  onRetry?.();
653
682
  } else if (Date.now() - start > 5 * 60 * 1000) {
654
683
  clearInterval(pollRef.current!);
684
+ stopVisibilityHandler();
655
685
  setConnecting(false);
656
686
  setConnectError(t("transcriptPanel.builderNoResponse"));
657
687
  }
658
688
  } catch {
659
- // transient poll error — keep trying
689
+ // coercion-ok: a transient poll error keeps the loop running; the
690
+ // 5-minute bound above surfaces builderNoResponse if it never succeeds.
691
+ } finally {
692
+ clearTimeout(abortTimer);
693
+ inFlightRef.current = false;
660
694
  }
661
- }, 2000);
662
- }, [onRetry]);
695
+ };
696
+ pollRef.current = setInterval(() => void tick(), 2000);
697
+ visibilityHandlerRef.current = () => {
698
+ if (!document.hidden) void tick();
699
+ };
700
+ document.addEventListener("visibilitychange", visibilityHandlerRef.current);
701
+ }, [onRetry, stopVisibilityHandler]);
663
702
 
664
703
  const isProviderError =
665
704
  !isBuilderCreditsExhaustedMessage(failureReason) &&
@@ -61,6 +61,18 @@ export function StorageSetupCard({
61
61
  const [err, setErr] = useState<string | null>(null);
62
62
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
63
63
  const mountedRef = useRef(true);
64
+ const inFlightRef = useRef(false);
65
+ const visibilityHandlerRef = useRef<(() => void) | null>(null);
66
+
67
+ const stopVisibilityHandler = useCallback(() => {
68
+ if (visibilityHandlerRef.current) {
69
+ document.removeEventListener(
70
+ "visibilitychange",
71
+ visibilityHandlerRef.current,
72
+ );
73
+ visibilityHandlerRef.current = null;
74
+ }
75
+ }, []);
64
76
 
65
77
  useEffect(() => {
66
78
  mountedRef.current = true;
@@ -70,14 +82,17 @@ export function StorageSetupCard({
70
82
  clearInterval(pollRef.current);
71
83
  pollRef.current = null;
72
84
  }
85
+ stopVisibilityHandler();
73
86
  };
74
- }, []);
87
+ }, [stopVisibilityHandler]);
75
88
 
76
89
  const handleConnect = useCallback(() => {
77
90
  if (pollRef.current) {
78
91
  clearInterval(pollRef.current);
79
92
  pollRef.current = null;
80
93
  }
94
+ stopVisibilityHandler();
95
+ inFlightRef.current = false;
81
96
  setConnecting(true);
82
97
  setErr(null);
83
98
 
@@ -93,14 +108,23 @@ export function StorageSetupCard({
93
108
  clearInterval(pollRef.current);
94
109
  pollRef.current = null;
95
110
  }
111
+ stopVisibilityHandler();
96
112
  };
97
- pollRef.current = setInterval(async () => {
113
+ const tick = async () => {
114
+ if (document.hidden || inFlightRef.current) return;
115
+ inFlightRef.current = true;
116
+ const controller = new AbortController();
117
+ const abortTimer = setTimeout(
118
+ () => controller.abort(),
119
+ Math.max(10_000, 2000 * 4),
120
+ );
98
121
  try {
99
122
  const r = await fetch(
100
123
  new URL(
101
124
  agentNativePath("/_agent-native/builder/status"),
102
125
  window.location.origin,
103
126
  ).toString(),
127
+ { signal: controller.signal },
104
128
  );
105
129
  if (!r.ok) return;
106
130
  const s = (await r.json()) as { configured: boolean };
@@ -119,10 +143,19 @@ export function StorageSetupCard({
119
143
  setErr(t("storageSetup.builderTimeout"));
120
144
  }
121
145
  } catch {
122
- // transient poll error
146
+ // coercion-ok: a transient poll error keeps the loop running; the
147
+ // timeoutMs bound above surfaces builderTimeout if it never succeeds.
148
+ } finally {
149
+ clearTimeout(abortTimer);
150
+ inFlightRef.current = false;
123
151
  }
124
- }, 2000);
125
- }, [onConfigured, connectSource, connectFlow]);
152
+ };
153
+ pollRef.current = setInterval(() => void tick(), 2000);
154
+ visibilityHandlerRef.current = () => {
155
+ if (!document.hidden) void tick();
156
+ };
157
+ document.addEventListener("visibilitychange", visibilityHandlerRef.current);
158
+ }, [onConfigured, connectSource, connectFlow, stopVisibilityHandler, t]);
126
159
 
127
160
  return (
128
161
  <div className="mx-auto flex w-full max-w-md flex-col gap-5 rounded-2xl border border-border bg-card p-6 shadow-lg">