@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
@@ -4,6 +4,7 @@ import { trackEvent } from "../analytics.js";
4
4
  import { agentNativePath } from "../api-path.js";
5
5
  import { getCallbackOrigin } from "../frame.js";
6
6
  import { openMcpAppHostLink } from "../mcp-app-host.js";
7
+ import { usePollLoop } from "../use-poll-loop.js";
7
8
  /**
8
9
  * Fetches Builder connection status from the neutral connection-status route.
9
10
  * The legacy /_agent-native/builder/status route remains available for older
@@ -80,6 +81,10 @@ export function useBuilderStatus({ enabled = true, } = {}) {
80
81
  }
81
82
  const POLL_INTERVAL_MS = 2000;
82
83
  const POLL_TIMEOUT_MS = 5 * 60 * 1000;
84
+ // Fallback timeout for callers of fetchStatus() with no external signal of
85
+ // their own (the initial status fetch, the popup-open branches in `start`).
86
+ // The connect-flow poll loop below gets its timeout from usePollLoop instead.
87
+ const STATUS_FETCH_ABORT_MS = 10_000;
83
88
  const CALLBACK_SUCCESS_STATUS_RETRY_MS = 500;
84
89
  const CALLBACK_SUCCESS_STATUS_RETRIES = 10;
85
90
  const BUILDER_CONNECT_PARAM = "_an_connect";
@@ -388,7 +393,6 @@ export function useBuilderConnectFlow(opts = {}) {
388
393
  // still-good direct URL (desktop) or refresh a new one inside the popup
389
394
  // gesture path (browser/editor embeds).
390
395
  const statusConnectUrlAtRef = useRef(null);
391
- const pollRef = useRef(null);
392
396
  const connectStartedAtRef = useRef(null);
393
397
  const mountedRef = useRef(true);
394
398
  const notifiedConnectedRef = useRef(false);
@@ -396,25 +400,42 @@ export function useBuilderConnectFlow(opts = {}) {
396
400
  // caller passes an inline arrow function.
397
401
  const onConnectedRef = useRef(onConnected);
398
402
  onConnectedRef.current = onConnected;
399
- const stopPoll = useCallback(() => {
400
- if (pollRef.current) {
401
- clearInterval(pollRef.current);
402
- pollRef.current = null;
403
- }
404
- }, []);
405
- const fetchStatus = useCallback(async () => {
403
+ // Tracking identity for whichever click is currently connecting, read by
404
+ // the poll loop's timeout-failure event below.
405
+ const activeTrackingRef = useRef({
406
+ source: trackingSource,
407
+ flow: trackingFlow,
408
+ });
409
+ // Accepts an optional external `signal` so the connect-flow poll loop below
410
+ // can cancel this fetch via its own timeout instead of racing a second,
411
+ // separately-constructed AbortController. Callers with no signal of their
412
+ // own (the initial status fetch, the popup-open branches in `start`) keep
413
+ // the internal fallback timeout.
414
+ const fetchStatus = useCallback(async (signal) => {
406
415
  if (!enabled)
407
416
  return null;
408
417
  const origin = getCallbackOrigin() || window.location.origin;
418
+ const ownController = !signal && typeof AbortController !== "undefined"
419
+ ? new AbortController()
420
+ : null;
421
+ const timeoutId = ownController
422
+ ? setTimeout(() => ownController.abort(), STATUS_FETCH_ABORT_MS)
423
+ : null;
409
424
  try {
410
- const r = await fetch(new URL(agentNativePath("/_agent-native/connection-status/builder"), origin).href);
425
+ const r = await fetch(new URL(agentNativePath("/_agent-native/connection-status/builder"), origin).href, { signal: signal ?? ownController?.signal });
411
426
  if (!r.ok)
412
427
  return null;
413
428
  return (await r.json());
414
429
  }
415
430
  catch {
431
+ // coercion-ok: null means "status unknown this tick" and callers hold
432
+ // their previous state rather than rendering a disconnected Builder.
416
433
  return null;
417
434
  }
435
+ finally {
436
+ if (timeoutId)
437
+ clearTimeout(timeoutId);
438
+ }
418
439
  }, [enabled]);
419
440
  // Initial fetch + focus/visibility refresh so if the user completed the
420
441
  // flow in another tab (or a downgraded same-tab nav) we notice it. Also
@@ -433,7 +454,6 @@ export function useBuilderConnectFlow(opts = {}) {
433
454
  setStatusResolved(false);
434
455
  setStatusConnectUrl(null);
435
456
  statusConnectUrlAtRef.current = null;
436
- stopPoll();
437
457
  return;
438
458
  }
439
459
  mountedRef.current = true;
@@ -501,17 +521,19 @@ export function useBuilderConnectFlow(opts = {}) {
501
521
  window.removeEventListener("focus", refresh);
502
522
  document.removeEventListener("visibilitychange", onVisible);
503
523
  window.removeEventListener("agent-engine:configured-changed", refresh);
504
- stopPoll();
505
524
  };
506
- }, [enabled, fetchStatus, stopPoll]);
525
+ }, [enabled, fetchStatus]);
507
526
  const start = useCallback((startOptions) => {
508
527
  if (!enabled)
509
528
  return;
510
- stopPoll();
511
529
  const started = Date.now();
512
530
  const clickTrackingSource = startOptions?.trackingSource ?? trackingSource;
513
531
  const clickTrackingFlow = startOptions?.trackingFlow ?? trackingFlow;
514
532
  connectStartedAtRef.current = started;
533
+ activeTrackingRef.current = {
534
+ source: clickTrackingSource,
535
+ flow: clickTrackingFlow,
536
+ };
515
537
  setConnecting(true);
516
538
  setError(null);
517
539
  // Open SYNCHRONOUSLY inside the caller's click handler — any await
@@ -580,7 +602,6 @@ export function useBuilderConnectFlow(opts = {}) {
580
602
  const openedByHost = await openBuilderConnectViaMcpHost(trackedHostUrl);
581
603
  if (!mountedRef.current || openedByHost)
582
604
  return;
583
- stopPoll();
584
605
  connectStartedAtRef.current = null;
585
606
  setConnecting(false);
586
607
  setError("Couldn't open Builder from this chat host. Open this app in a browser tab and try Connect Builder (free tier available) again.");
@@ -628,7 +649,6 @@ export function useBuilderConnectFlow(opts = {}) {
628
649
  catch {
629
650
  // Ignore close failures.
630
651
  }
631
- stopPoll();
632
652
  connectStartedAtRef.current = null;
633
653
  setConnecting(false);
634
654
  setError("Couldn't start Builder connect. Refresh this page and try again.");
@@ -639,7 +659,6 @@ export function useBuilderConnectFlow(opts = {}) {
639
659
  flow: clickTrackingFlow,
640
660
  });
641
661
  if (!navigateBuilderConnectPopup(opened, trackedFreshUrl)) {
642
- stopPoll();
643
662
  connectStartedAtRef.current = null;
644
663
  setConnecting(false);
645
664
  setError("Couldn't navigate the Builder popup. Allow popups and try again.");
@@ -647,66 +666,72 @@ export function useBuilderConnectFlow(opts = {}) {
647
666
  })();
648
667
  }
649
668
  }
650
- pollRef.current = setInterval(async () => {
651
- const s = await fetchStatus();
652
- if (!mountedRef.current) {
653
- stopPoll();
654
- return;
655
- }
656
- if (s?.configured) {
657
- stopPoll();
658
- setConfigured(true);
659
- setEnvManaged(!!s.envManaged);
660
- setBuilderEnabled(!!s.builderEnabled);
661
- const nextConnectUrl = s.cliAuthUrl ?? s.connectUrl ?? null;
662
- setStatusConnectUrl(nextConnectUrl);
663
- statusConnectUrlAtRef.current = nextConnectUrl ? Date.now() : null;
664
- const org = s.orgName ?? null;
665
- setOrgName(org);
666
- setConnecting(false);
667
- connectStartedAtRef.current = null;
668
- notifiedConnectedRef.current = true;
669
- notifyAgentEngineConfiguredChanged("builder-connect");
670
- try {
671
- await onConnectedRef.current?.({ orgName: org });
672
- }
673
- catch {
674
- // Consumer's callback failed; we've already flipped the UI state
675
- // to connected. Swallow so we don't re-arm the flow.
676
- }
677
- }
678
- else if (isCurrentConnectError(s?.connectError, started)) {
679
- // OAuth callback ran but writeBuilderCredentials threw — surface the
680
- // real error instead of letting the user wait 5 minutes for timeout.
681
- stopPoll();
682
- connectStartedAtRef.current = null;
683
- setConnecting(false);
684
- setError(`Couldn't save Builder credentials: ${s.connectError.message}. Try again or contact support.`);
685
- }
686
- else if (Date.now() - started > POLL_TIMEOUT_MS) {
687
- stopPoll();
688
- connectStartedAtRef.current = null;
689
- setConnecting(false);
690
- trackEvent("builder connect failed", {
691
- feature: "builder",
692
- stage: "client",
693
- reason: "timeout",
694
- source: clickTrackingSource,
695
- flow: cleanTrackingParam(clickTrackingFlow) ??
696
- inferBuilderConnectTrackingFlow(clickTrackingSource),
697
- });
698
- setError("Didn't hear back from Builder in 5 minutes. Allow popups and try again.");
699
- }
700
- }, POLL_INTERVAL_MS);
701
669
  }, [
702
670
  enabled,
703
671
  fetchStatus,
704
672
  popupUrl,
705
673
  statusConnectUrl,
706
- stopPoll,
707
674
  trackingFlow,
708
675
  trackingSource,
709
676
  ]);
677
+ // Connect-flow poll: while `connecting`, checks the server every 2s for the
678
+ // popup callback to have landed. `enabled` here folds in both the hook's
679
+ // own `enabled` option and the 5-minute overall cap (checked below and
680
+ // enforced by flipping `connecting` false, which then disables this loop).
681
+ usePollLoop(async (signal) => {
682
+ const started = connectStartedAtRef.current;
683
+ if (started == null)
684
+ return;
685
+ const s = await fetchStatus(signal);
686
+ if (!mountedRef.current)
687
+ return;
688
+ if (s?.configured) {
689
+ setConfigured(true);
690
+ setEnvManaged(!!s.envManaged);
691
+ setBuilderEnabled(!!s.builderEnabled);
692
+ const nextConnectUrl = s.cliAuthUrl ?? s.connectUrl ?? null;
693
+ setStatusConnectUrl(nextConnectUrl);
694
+ statusConnectUrlAtRef.current = nextConnectUrl ? Date.now() : null;
695
+ const org = s.orgName ?? null;
696
+ setOrgName(org);
697
+ setConnecting(false);
698
+ connectStartedAtRef.current = null;
699
+ notifiedConnectedRef.current = true;
700
+ notifyAgentEngineConfiguredChanged("builder-connect");
701
+ try {
702
+ await onConnectedRef.current?.({ orgName: org });
703
+ }
704
+ catch {
705
+ // coercion-ok: the connection itself succeeded and the UI state is
706
+ // already flipped; re-arming the flow on a consumer callback failure
707
+ // would reconnect an account that is connected.
708
+ }
709
+ }
710
+ else if (isCurrentConnectError(s?.connectError, started)) {
711
+ // OAuth callback ran but writeBuilderCredentials threw \u2014 surface the
712
+ // real error instead of letting the user wait 5 minutes for timeout.
713
+ connectStartedAtRef.current = null;
714
+ setConnecting(false);
715
+ setError(`Couldn't save Builder credentials: ${s.connectError.message}. Try again or contact support.`);
716
+ }
717
+ else if (Date.now() - started > POLL_TIMEOUT_MS) {
718
+ connectStartedAtRef.current = null;
719
+ setConnecting(false);
720
+ const { source, flow } = activeTrackingRef.current;
721
+ trackEvent("builder connect failed", {
722
+ feature: "builder",
723
+ stage: "client",
724
+ reason: "timeout",
725
+ source,
726
+ flow: cleanTrackingParam(flow) ?? inferBuilderConnectTrackingFlow(source),
727
+ });
728
+ setError("Didn't hear back from Builder in 5 minutes. Allow popups and try again.");
729
+ }
730
+ }, {
731
+ intervalMs: POLL_INTERVAL_MS,
732
+ leading: false,
733
+ enabled: enabled && connecting,
734
+ });
710
735
  // Popup-side fast path: the callback page broadcasts a message so we stop
711
736
  // polling immediately rather than waiting for the next 2s tick.
712
737
  //
@@ -714,11 +739,10 @@ export function useBuilderConnectFlow(opts = {}) {
714
739
  // AND on window.message (legacy path for environments without BC or for
715
740
  // popups that still have opener access). Both paths are safe to have open
716
741
  // simultaneously \u2014 the first one to fire wins and the error is deduplicated
717
- // by the stopPoll() call which is idempotent.
742
+ // by the setConnecting(false) call, which is idempotent.
718
743
  useEffect(() => {
719
744
  let channel = null;
720
745
  const handleError = (message) => {
721
- stopPoll();
722
746
  connectStartedAtRef.current = null;
723
747
  setConnecting(false);
724
748
  setError(`Couldn't save Builder credentials: ${message}.`);
@@ -754,14 +778,12 @@ export function useBuilderConnectFlow(opts = {}) {
754
778
  setOrgName(s.orgName ?? null);
755
779
  }
756
780
  if (connectError) {
757
- stopPoll();
758
781
  connectStartedAtRef.current = null;
759
782
  setConnecting(false);
760
783
  setError(`Couldn't save Builder credentials: ${connectError.message}. Try again or contact support.`);
761
784
  }
762
785
  return;
763
786
  }
764
- stopPoll();
765
787
  setHasFetchedStatus(true);
766
788
  setConfigured(true);
767
789
  setEnvManaged(!!s.envManaged);
@@ -819,7 +841,7 @@ export function useBuilderConnectFlow(opts = {}) {
819
841
  channel?.close();
820
842
  window.removeEventListener("message", handler);
821
843
  };
822
- }, [fetchStatus, stopPoll]);
844
+ }, [fetchStatus]);
823
845
  return {
824
846
  configured,
825
847
  statusResolved,
@@ -1,3 +1,4 @@
1
+ import { getOrCreateAnalyticsSessionId } from "./analytics-session.js";
1
2
  import { agentNativePath } from "./api-path.js";
2
3
  /**
3
4
  * Fire an analytics event from browser/app code. This is the client-side twin
@@ -35,6 +36,7 @@ export function track(name, properties) {
35
36
  // Non-serializable properties — drop rather than throw into the caller.
36
37
  return Promise.resolve();
37
38
  }
39
+ const browserSessionId = getOrCreateAnalyticsSessionId();
38
40
  return fetch(agentNativePath("/_agent-native/track"), {
39
41
  method: "POST",
40
42
  headers: {
@@ -43,6 +45,11 @@ export function track(name, properties) {
43
45
  // middleware trusts it as a first-party marker. Matches the convention
44
46
  // used by other client writes (application-state, guided-questions).
45
47
  "X-Agent-Native-CSRF": "1",
48
+ // Same session the action client and agent chat send, so a client event
49
+ // and the server events from the same visit share one session.
50
+ ...(browserSessionId
51
+ ? { "X-Agent-Native-Session-Id": browserSessionId }
52
+ : {}),
46
53
  },
47
54
  body,
48
55
  keepalive: true,
@@ -10,12 +10,14 @@ import { IconBolt, IconLoader2 } from "@tabler/icons-react";
10
10
  import { useCallback, useEffect, useRef, useState } from "react";
11
11
  import { agentNativePath } from "../api-path.js";
12
12
  import { openBuilderConnectPopup } from "../settings/useBuilderStatus.js";
13
+ import { usePollLoop } from "../use-poll-loop.js";
14
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000;
13
15
  export function BuilderTranscriptionCta() {
14
16
  const [configured, setConfigured] = useState(null);
15
17
  const [connectUrl, setConnectUrl] = useState(null);
16
18
  const [connecting, setConnecting] = useState(false);
17
19
  const [error, setError] = useState(null);
18
- const pollRef = useRef(null);
20
+ const connectStartedAtRef = useRef(null);
19
21
  const mountedRef = useRef(true);
20
22
  useEffect(() => {
21
23
  mountedRef.current = true;
@@ -37,46 +39,40 @@ export function BuilderTranscriptionCta() {
37
39
  });
38
40
  return () => {
39
41
  mountedRef.current = false;
40
- if (pollRef.current)
41
- clearInterval(pollRef.current);
42
42
  };
43
43
  }, []);
44
44
  const handleConnect = useCallback(() => {
45
- if (pollRef.current)
46
- clearInterval(pollRef.current);
45
+ connectStartedAtRef.current = Date.now();
47
46
  setConnecting(true);
48
47
  setError(null);
49
48
  openBuilderConnectPopup({
50
49
  url: connectUrl ?? undefined,
51
50
  source: "builder_transcription_cta",
52
51
  });
53
- const start = Date.now();
54
- pollRef.current = setInterval(async () => {
55
- try {
56
- const r = await fetch(agentNativePath("/_agent-native/builder/status"));
57
- if (!r.ok)
58
- return;
59
- const s = (await r.json());
60
- if (!mountedRef.current) {
61
- clearInterval(pollRef.current);
62
- return;
63
- }
64
- if (s.configured) {
65
- clearInterval(pollRef.current);
66
- setConfigured(true);
67
- setConnecting(false);
68
- }
69
- else if (Date.now() - start > 5 * 60 * 1000) {
70
- clearInterval(pollRef.current);
71
- setConnecting(false);
72
- setError("Didn't hear back from Builder. Allow popups and try again.");
73
- }
74
- }
75
- catch {
76
- // transient — keep polling
77
- }
78
- }, 2000);
79
52
  }, [connectUrl]);
53
+ usePollLoop(async (signal) => {
54
+ const started = connectStartedAtRef.current;
55
+ if (started == null)
56
+ return;
57
+ const r = await fetch(agentNativePath("/_agent-native/builder/status"), {
58
+ signal,
59
+ });
60
+ if (!r.ok || !mountedRef.current)
61
+ return;
62
+ const s = (await r.json());
63
+ if (!mountedRef.current)
64
+ return;
65
+ if (s.configured) {
66
+ connectStartedAtRef.current = null;
67
+ setConfigured(true);
68
+ setConnecting(false);
69
+ }
70
+ else if (Date.now() - started > POLL_TIMEOUT_MS) {
71
+ connectStartedAtRef.current = null;
72
+ setConnecting(false);
73
+ setError("Didn't hear back from Builder. Allow popups and try again.");
74
+ }
75
+ }, { intervalMs: 2000, leading: false, enabled: connecting });
80
76
  // Already connected or still loading — render nothing
81
77
  if (configured === null || configured)
82
78
  return null;
@@ -22,6 +22,7 @@
22
22
  * compatibility.
23
23
  */
24
24
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
25
+ import { getOrCreateAnalyticsSessionId } from "./analytics-session.js";
25
26
  import { trackEvent } from "./analytics.js";
26
27
  import { agentNativePath } from "./api-path.js";
27
28
  import { getBrowserTabId } from "./browser-tab-id.js";
@@ -192,8 +193,16 @@ async function performActionFetch(name, method, params, options) {
192
193
  if (buildId)
193
194
  headers["X-Agent-Native-Build-Id"] = buildId;
194
195
  const tz = resolveUserTimezone();
195
- if (tz)
196
+ if (tz) {
196
197
  headers["x-user-timezone"] = tz;
198
+ }
199
+ // Same browser session the agent chat sends on an agent run, so an action
200
+ // called from the UI and an action the agent calls during that visit land on
201
+ // one `$session_id` in traces and session replay.
202
+ const browserSessionId = getOrCreateAnalyticsSessionId();
203
+ if (browserSessionId) {
204
+ headers["X-Agent-Native-Session-Id"] = browserSessionId;
205
+ }
197
206
  const init = {
198
207
  method,
199
208
  headers,
@@ -95,6 +95,17 @@ export function useAgentEngineConfigured(enabled = true, options) {
95
95
  let requestSeq = 0;
96
96
  let retryTimer;
97
97
  let retryAttempt = 0;
98
+ const scheduleRetry = (delay) => {
99
+ retryTimer = setTimeout(() => {
100
+ if (document.hidden) {
101
+ // Tab is backgrounded — keep the same backoff instead of hitting
102
+ // the network; the visibilitychange listener below recovers fast.
103
+ scheduleRetry(delay);
104
+ return;
105
+ }
106
+ void check();
107
+ }, delay);
108
+ };
98
109
  const check = async (options) => {
99
110
  const seq = ++requestSeq;
100
111
  if (retryTimer !== undefined) {
@@ -114,9 +125,7 @@ export function useAgentEngineConfigured(enabled = true, options) {
114
125
  // back short of a reload.
115
126
  const delay = Math.min(RETRY_BASE_MS * 2 ** retryAttempt, RETRY_MAX_MS);
116
127
  retryAttempt += 1;
117
- retryTimer = setTimeout(() => {
118
- void check();
119
- }, delay);
128
+ scheduleRetry(delay);
120
129
  };
121
130
  const onConfiguredChanged = () => {
122
131
  void check();
@@ -130,15 +139,24 @@ export function useAgentEngineConfigured(enabled = true, options) {
130
139
  }
131
140
  void check({ missingFallback: true });
132
141
  };
142
+ const onVisibilityChange = () => {
143
+ if (!document.hidden && retryTimer !== undefined) {
144
+ clearTimeout(retryTimer);
145
+ retryTimer = undefined;
146
+ void check();
147
+ }
148
+ };
133
149
  void check();
134
150
  window.addEventListener("agent-engine:configured-changed", onConfiguredChanged);
135
151
  // A stale failed stream can arrive after a reconnect succeeds. Re-check the
136
152
  // current status before pinning the composer in setup.
137
153
  window.addEventListener("agent-chat:missing-api-key", onMissing);
154
+ document.addEventListener("visibilitychange", onVisibilityChange);
138
155
  return () => {
139
156
  cancelled = true;
140
157
  if (retryTimer !== undefined)
141
158
  clearTimeout(retryTimer);
159
+ document.removeEventListener("visibilitychange", onVisibilityChange);
142
160
  window.removeEventListener("agent-engine:configured-changed", onConfiguredChanged);
143
161
  window.removeEventListener("agent-chat:missing-api-key", onMissing);
144
162
  };
@@ -6,5 +6,11 @@
6
6
  * Pass `pollMs=0` to disable. Pass `pauseWhenHidden=false` to keep the
7
7
  * interval running even when the tab is hidden — the bell's browser-
8
8
  * notification popup loop uses that to still reach backgrounded tabs.
9
+ *
10
+ * @deprecated Use `usePollLoop` instead — it has the same visibility
11
+ * semantics plus a built-in per-attempt timeout and settle-then-reschedule
12
+ * (rather than `setInterval`) scheduling, so a hung `callback` can't wedge
13
+ * the loop. No remaining callers in this codebase; new polling code should
14
+ * use `usePollLoop` directly.
9
15
  */
10
16
  export declare function usePausingInterval(callback: () => void | Promise<void>, pollMs: number, pauseWhenHidden?: boolean): void;
@@ -7,6 +7,12 @@ import { useEffect } from "react";
7
7
  * Pass `pollMs=0` to disable. Pass `pauseWhenHidden=false` to keep the
8
8
  * interval running even when the tab is hidden — the bell's browser-
9
9
  * notification popup loop uses that to still reach backgrounded tabs.
10
+ *
11
+ * @deprecated Use `usePollLoop` instead — it has the same visibility
12
+ * semantics plus a built-in per-attempt timeout and settle-then-reschedule
13
+ * (rather than `setInterval`) scheduling, so a hung `callback` can't wedge
14
+ * the loop. No remaining callers in this codebase; new polling code should
15
+ * use `usePollLoop` directly.
10
16
  */
11
17
  export function usePausingInterval(callback, pollMs, pauseWhenHidden = true) {
12
18
  useEffect(() => {
@@ -0,0 +1,39 @@
1
+ export interface UsePollLoopOptions {
2
+ /** Delay in ms between attempts while the tab is visible. */
3
+ intervalMs: number;
4
+ /** Per-attempt timeout. Default: `Math.max(timeoutFloorMs, intervalMs * 4)`. */
5
+ timeoutMs?: number;
6
+ /** Floor used by the default `timeoutMs`. Default: 10_000. */
7
+ timeoutFloorMs?: number;
8
+ /** Called when an attempt throws or times out. Default: swallow. */
9
+ onError?: (err: unknown) => void;
10
+ /** Run the first attempt immediately on mount. Default: true. */
11
+ leading?: boolean;
12
+ /** Set to false to tear the loop down without unmounting the caller. Default: true. */
13
+ enabled?: boolean;
14
+ /**
15
+ * true: fully stop while the tab is hidden, resuming immediately when it
16
+ * becomes visible again. false (default): keep polling at a relaxed
17
+ * cadence (`Math.max(intervalMs, hiddenIntervalFloorMs)`) — use this for
18
+ * loops that must still reach a backgrounded tab (e.g. browser
19
+ * notifications).
20
+ */
21
+ pauseWhenHidden?: boolean;
22
+ /** Floor for the relaxed hidden-tab cadence when `pauseWhenHidden` is false. Default: 10_000. */
23
+ hiddenIntervalFloorMs?: number;
24
+ }
25
+ export interface UsePollLoopHandle {
26
+ /** Cancel the pending wait and run an attempt now. */
27
+ pollNow: () => void;
28
+ }
29
+ /**
30
+ * Poll a network endpoint on a recurring cadence without hand-rolling a
31
+ * visibility check, an in-flight guard, or a request timeout — see
32
+ * `createPollEngine` for the underlying guarantees. `attempt` receives an
33
+ * `AbortSignal`; pass it to `fetch` for real cancellation on timeout.
34
+ *
35
+ * Supersedes `usePausingInterval`, which pauses/resumes on visibility but has
36
+ * no request timeout and schedules via `setInterval` rather than a
37
+ * settle-then-reschedule loop.
38
+ */
39
+ export declare function usePollLoop(attempt: (signal: AbortSignal) => Promise<void>, options: UsePollLoopOptions): UsePollLoopHandle;
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { createPollEngine } from "../shared/poll-engine.js";
3
+ const HIDDEN_INTERVAL_FLOOR_MS = 10_000;
4
+ function isDocumentHidden() {
5
+ return (typeof document !== "undefined" && document.visibilityState === "hidden");
6
+ }
7
+ /**
8
+ * Poll a network endpoint on a recurring cadence without hand-rolling a
9
+ * visibility check, an in-flight guard, or a request timeout — see
10
+ * `createPollEngine` for the underlying guarantees. `attempt` receives an
11
+ * `AbortSignal`; pass it to `fetch` for real cancellation on timeout.
12
+ *
13
+ * Supersedes `usePausingInterval`, which pauses/resumes on visibility but has
14
+ * no request timeout and schedules via `setInterval` rather than a
15
+ * settle-then-reschedule loop.
16
+ */
17
+ export function usePollLoop(attempt, options) {
18
+ const attemptRef = useRef(attempt);
19
+ attemptRef.current = attempt;
20
+ const pollNowRef = useRef(() => { });
21
+ const { intervalMs, timeoutMs, timeoutFloorMs, onError, leading, enabled = true, pauseWhenHidden = false, hiddenIntervalFloorMs = HIDDEN_INTERVAL_FLOOR_MS, } = options;
22
+ useEffect(() => {
23
+ if (!enabled)
24
+ return;
25
+ const engine = createPollEngine((signal) => attemptRef.current(signal), {
26
+ intervalMs: pauseWhenHidden
27
+ ? intervalMs
28
+ : () => isDocumentHidden()
29
+ ? Math.max(intervalMs, hiddenIntervalFloorMs)
30
+ : intervalMs,
31
+ timeoutMs,
32
+ timeoutFloorMs,
33
+ onError,
34
+ leading,
35
+ });
36
+ pollNowRef.current = () => engine.pollNow();
37
+ // Mounting in an already-hidden tab (background restore, prerender) must
38
+ // not fire the leading attempt when pauseWhenHidden promises a full pause
39
+ // while hidden — the visibility handler below starts the loop instead.
40
+ if (!pauseWhenHidden || !isDocumentHidden())
41
+ engine.start();
42
+ const onVisibilityChange = () => {
43
+ if (isDocumentHidden()) {
44
+ if (pauseWhenHidden)
45
+ engine.stop();
46
+ else
47
+ engine.reschedule();
48
+ }
49
+ else if (pauseWhenHidden) {
50
+ engine.start();
51
+ }
52
+ else {
53
+ engine.pollNow();
54
+ }
55
+ };
56
+ document.addEventListener("visibilitychange", onVisibilityChange);
57
+ return () => {
58
+ engine.stop();
59
+ pollNowRef.current = () => { };
60
+ document.removeEventListener("visibilitychange", onVisibilityChange);
61
+ };
62
+ }, [
63
+ intervalMs,
64
+ timeoutMs,
65
+ timeoutFloorMs,
66
+ onError,
67
+ leading,
68
+ enabled,
69
+ pauseWhenHidden,
70
+ hiddenIntervalFloorMs,
71
+ ]); // eslint-disable-line react-hooks/exhaustive-deps
72
+ return { pollNow: () => pollNowRef.current() };
73
+ }