@agent-native/core 0.144.2 → 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 (70) 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/browser-session-bridge.js +97 -54
  26. package/dist/client/db-admin/useAgentSync.js +34 -43
  27. package/dist/client/dynamic-suggestions.d.ts +3 -0
  28. package/dist/client/dynamic-suggestions.js +54 -52
  29. package/dist/client/hooks/index.d.ts +1 -0
  30. package/dist/client/hooks/index.js +1 -0
  31. package/dist/client/i18n.d.ts +7 -0
  32. package/dist/client/i18n.js +12 -3
  33. package/dist/client/integrations/useIntegrationStatus.d.ts +1 -1
  34. package/dist/client/integrations/useIntegrationStatus.js +9 -22
  35. package/dist/client/notifications/NotificationsBell.js +14 -9
  36. package/dist/client/progress/RunsTray.js +6 -5
  37. package/dist/client/settings/useBuilderStatus.js +95 -73
  38. package/dist/client/transcription/BuilderTranscriptionCta.js +27 -31
  39. package/dist/client/use-agent-engine-configured.js +21 -3
  40. package/dist/client/use-pausing-interval.d.ts +6 -0
  41. package/dist/client/use-pausing-interval.js +6 -0
  42. package/dist/client/use-poll-loop.d.ts +39 -0
  43. package/dist/client/use-poll-loop.js +73 -0
  44. package/dist/client/use-run-stuck-detection.js +41 -13
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/integrations/google-docs-poller.js +26 -7
  47. package/dist/integrations/pending-tasks-retry-job.js +17 -23
  48. package/dist/integrations/plugin.js +16 -15
  49. package/dist/integrations/remote-push-delivery-job.js +16 -25
  50. package/dist/integrations/remote-retry-job.js +19 -23
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/oauth-tokens/google-refresh.js +12 -19
  53. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  54. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  55. package/dist/server/agent-chat-plugin.js +26 -29
  56. package/dist/server/index.d.ts +1 -0
  57. package/dist/server/index.js +1 -0
  58. package/dist/server/interval-job.d.ts +25 -0
  59. package/dist/server/interval-job.js +19 -0
  60. package/dist/server/realtime-token.d.ts +1 -1
  61. package/dist/shared/index.d.ts +1 -0
  62. package/dist/shared/index.js +1 -0
  63. package/dist/shared/poll-engine.d.ts +49 -0
  64. package/dist/shared/poll-engine.js +148 -0
  65. package/dist/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  66. package/dist/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
  67. package/dist/tracking/providers.js +10 -1
  68. package/package.json +4 -3
  69. package/src/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  70. package/src/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
@@ -66,6 +66,16 @@ const STEPS = [
66
66
  },
67
67
  ];
68
68
 
69
+ const DESKTOP_POLL_INTERVAL_MS = 1500;
70
+ const ADD_ACCOUNT_POLL_INTERVAL_MS = 2000;
71
+ // Bounds each poll's fetches so a hung request can't leave its in-flight
72
+ // guard stuck and stall the interval forever.
73
+ const DESKTOP_POLL_ABORT_MS = Math.max(10_000, DESKTOP_POLL_INTERVAL_MS * 4);
74
+ const ADD_ACCOUNT_POLL_ABORT_MS = Math.max(
75
+ 10_000,
76
+ ADD_ACCOUNT_POLL_INTERVAL_MS * 4,
77
+ );
78
+
69
79
  interface GoogleConnectBannerProps {
70
80
  variant?: "banner" | "hero";
71
81
  }
@@ -104,6 +114,8 @@ export function GoogleConnectBanner({
104
114
  );
105
115
  const desktopPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
106
116
  const addAccountPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
117
+ const desktopPollInFlightRef = useRef(false);
118
+ const addAccountPollInFlightRef = useRef(false);
107
119
  useEffect(() => {
108
120
  return () => {
109
121
  if (desktopPollRef.current) clearInterval(desktopPollRef.current);
@@ -130,40 +142,54 @@ export function GoogleConnectBanner({
130
142
  const start = Date.now();
131
143
  if (desktopPollRef.current) clearInterval(desktopPollRef.current);
132
144
  desktopPollRef.current = setInterval(async () => {
145
+ if (document.hidden || desktopPollInFlightRef.current) return;
146
+ desktopPollInFlightRef.current = true;
147
+ const controller = new AbortController();
148
+ const abortTimer = setTimeout(
149
+ () => controller.abort(),
150
+ DESKTOP_POLL_ABORT_MS,
151
+ );
133
152
  try {
134
- const res = await fetch(
135
- agentNativePath(
136
- `/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
137
- ),
138
- );
139
- const data = await res.json();
140
- if (data?.error) {
141
- clearInterval(desktopPollRef.current!);
142
- desktopPollRef.current = null;
143
- setDesktopAuthIssue(data);
144
- } else if (data?.token) {
145
- clearInterval(desktopPollRef.current!);
146
- desktopPollRef.current = null;
147
- await fetch(
153
+ try {
154
+ const res = await fetch(
148
155
  agentNativePath(
149
- `/_agent-native/auth/session?_session=${data.token}`,
156
+ `/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
150
157
  ),
151
- {
152
- credentials: "include",
153
- },
158
+ { signal: controller.signal },
154
159
  );
155
- window.location.reload();
156
- } else if (Date.now() - start > 120_000) {
157
- clearInterval(desktopPollRef.current!);
158
- desktopPollRef.current = null;
159
- }
160
- } catch {
161
- if (Date.now() - start > 120_000) {
162
- clearInterval(desktopPollRef.current!);
163
- desktopPollRef.current = null;
160
+ const data = await res.json();
161
+ if (data?.error) {
162
+ clearInterval(desktopPollRef.current!);
163
+ desktopPollRef.current = null;
164
+ setDesktopAuthIssue(data);
165
+ } else if (data?.token) {
166
+ clearInterval(desktopPollRef.current!);
167
+ desktopPollRef.current = null;
168
+ await fetch(
169
+ agentNativePath(
170
+ `/_agent-native/auth/session?_session=${data.token}`,
171
+ ),
172
+ {
173
+ credentials: "include",
174
+ signal: controller.signal,
175
+ },
176
+ );
177
+ window.location.reload();
178
+ } else if (Date.now() - start > 120_000) {
179
+ clearInterval(desktopPollRef.current!);
180
+ desktopPollRef.current = null;
181
+ }
182
+ } catch {
183
+ if (Date.now() - start > 120_000) {
184
+ clearInterval(desktopPollRef.current!);
185
+ desktopPollRef.current = null;
186
+ }
164
187
  }
188
+ } finally {
189
+ clearTimeout(abortTimer);
190
+ desktopPollInFlightRef.current = false;
165
191
  }
166
- }, 1500);
192
+ }, DESKTOP_POLL_INTERVAL_MS);
167
193
  }
168
194
 
169
195
  const [authError, setAuthError] = useState<string | null>(null);
@@ -274,20 +300,37 @@ export function GoogleConnectBanner({
274
300
  const prevCount = accounts.length;
275
301
  if (addAccountPollRef.current) clearInterval(addAccountPollRef.current);
276
302
  addAccountPollRef.current = setInterval(async () => {
277
- const res = await fetch(
278
- agentNativePath("/_agent-native/google/status"),
279
- ).catch(() => null);
280
- if (res?.ok) {
281
- const data = await res.json();
282
- if (data.accounts?.length > prevCount) {
283
- if (addAccountPollRef.current) {
284
- clearInterval(addAccountPollRef.current);
285
- addAccountPollRef.current = null;
303
+ if (document.hidden || addAccountPollInFlightRef.current) return;
304
+ addAccountPollInFlightRef.current = true;
305
+ const controller = new AbortController();
306
+ const abortTimer = setTimeout(
307
+ () => controller.abort(),
308
+ ADD_ACCOUNT_POLL_ABORT_MS,
309
+ );
310
+ try {
311
+ const res = await fetch(
312
+ agentNativePath("/_agent-native/google/status"),
313
+ { signal: controller.signal },
314
+ )
315
+ // coercion-ok: a failed probe and a not-yet-added-account response
316
+ // both mean "keep waiting"; this loop only acts on an observed
317
+ // account-count increase.
318
+ .catch(() => null);
319
+ if (res?.ok) {
320
+ const data = await res.json();
321
+ if (data.accounts?.length > prevCount) {
322
+ if (addAccountPollRef.current) {
323
+ clearInterval(addAccountPollRef.current);
324
+ addAccountPollRef.current = null;
325
+ }
326
+ window.location.reload();
286
327
  }
287
- window.location.reload();
288
328
  }
329
+ } finally {
330
+ clearTimeout(abortTimer);
331
+ addAccountPollInFlightRef.current = false;
289
332
  }
290
- }, 2000);
333
+ }, ADD_ACCOUNT_POLL_INTERVAL_MS);
291
334
  // accounts.length is captured into prevCount above; including it in deps
292
335
  // would tear down and recreate the interval whenever the count changes.
293
336
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -96,6 +96,10 @@ type SnoozeTarget = {
96
96
  };
97
97
  const COMPOSE_FULLSCREEN_PARAM = "composeFullscreen";
98
98
  const SIDEBAR_COLLAPSE_KEY = "mail-sidebar-collapsed";
99
+ const ACCOUNT_POLL_INTERVAL_MS = 2000;
100
+ // Bounds the account-status poll so a hung fetch can't leave the in-flight
101
+ // guard stuck and stall the interval forever.
102
+ const ACCOUNT_POLL_ABORT_MS = Math.max(10_000, ACCOUNT_POLL_INTERVAL_MS * 4);
99
103
 
100
104
  function AccountAvatar({
101
105
  email,
@@ -2491,18 +2495,36 @@ function AccountPopover({
2491
2495
  setWantAuthUrl(false);
2492
2496
  window.open(authUrl.data.url, "_blank");
2493
2497
 
2498
+ let inFlight = false;
2494
2499
  const interval = setInterval(async () => {
2495
- const res = await fetch(
2496
- agentNativePath("/_agent-native/google/status"),
2497
- ).catch(() => null);
2498
- if (res?.ok) {
2499
- const data = await res.json();
2500
- if (data.accounts?.length > accounts.length) {
2501
- clearInterval(interval);
2502
- window.location.reload();
2500
+ if (document.hidden || inFlight) return;
2501
+ inFlight = true;
2502
+ const controller = new AbortController();
2503
+ const abortTimer = setTimeout(
2504
+ () => controller.abort(),
2505
+ ACCOUNT_POLL_ABORT_MS,
2506
+ );
2507
+ try {
2508
+ const res = await fetch(
2509
+ agentNativePath("/_agent-native/google/status"),
2510
+ { signal: controller.signal },
2511
+ )
2512
+ // coercion-ok: a failed probe and a not-yet-added-account response
2513
+ // both mean "keep waiting"; this loop only acts on an observed
2514
+ // account-count increase.
2515
+ .catch(() => null);
2516
+ if (res?.ok) {
2517
+ const data = await res.json();
2518
+ if (data.accounts?.length > accounts.length) {
2519
+ clearInterval(interval);
2520
+ window.location.reload();
2521
+ }
2503
2522
  }
2523
+ } finally {
2524
+ clearTimeout(abortTimer);
2525
+ inFlight = false;
2504
2526
  }
2505
- }, 2000);
2527
+ }, ACCOUNT_POLL_INTERVAL_MS);
2506
2528
 
2507
2529
  return () => clearInterval(interval);
2508
2530
  }, [wantAuthUrl, authUrl.data, accounts.length]);
@@ -1,5 +1,6 @@
1
1
  import { registerEvent } from "@agent-native/core/event-bus";
2
2
  import { listOAuthAccounts } from "@agent-native/core/oauth-tokens";
3
+ import { startIntervalJob } from "@agent-native/core/server/interval-job";
3
4
  import { z } from "zod";
4
5
 
5
6
  import { processAutomations } from "../lib/automation-engine.js";
@@ -18,6 +19,10 @@ import {
18
19
 
19
20
  const INTERVAL_MS = 60_000; // 1 minute
20
21
  const WATCH_RENEW_INTERVAL_MS = 12 * 60 * 60_000;
22
+ // Backstop for the whole tick (job sends + Gmail watch renewal, both outbound
23
+ // network calls with no timeout of their own), reported through the job's
24
+ // onError so a wedged tick is loud rather than silent.
25
+ const TICK_ABORT_MS = Math.max(10_000, INTERVAL_MS * 4);
21
26
  let lastWatchRenewalAt = 0;
22
27
  // Vite's dev server initializes Nitro plugins more than once during boot
23
28
  // (initial load + post-init). Module-scope flag ensures the "skipping" log
@@ -124,24 +129,38 @@ export default () => {
124
129
  return;
125
130
  }
126
131
 
127
- setInterval(async () => {
128
- try {
129
- await processJobs();
130
- } catch (err) {
131
- console.error("[mail-jobs] processJobs failed:", err);
132
- }
133
- try {
134
- await processAutomations();
135
- } catch (err) {
136
- console.error("[mail-jobs] processAutomations failed:", err);
137
- }
138
- if (Date.now() - lastWatchRenewalAt > WATCH_RENEW_INTERVAL_MS) {
139
- lastWatchRenewalAt = Date.now();
132
+ // The overlap guard and the hung-tick timeout both come from
133
+ // startIntervalJob rather than a module-level `running` flag here: these
134
+ // are outbound Google calls with no timeout of their own, and releasing the
135
+ // guard on the timeout while a hung call kept running is what let a tick
136
+ // overlap the next one and send duplicate mail.
137
+ startIntervalJob(
138
+ async () => {
140
139
  try {
141
- await renewAllWatches();
140
+ await processJobs();
142
141
  } catch (err) {
143
- console.error("[mail-jobs] renewAllWatches failed:", err);
142
+ console.error("[mail-jobs] processJobs failed:", err);
144
143
  }
145
- }
146
- }, INTERVAL_MS);
144
+ try {
145
+ await processAutomations();
146
+ } catch (err) {
147
+ console.error("[mail-jobs] processAutomations failed:", err);
148
+ }
149
+ if (Date.now() - lastWatchRenewalAt > WATCH_RENEW_INTERVAL_MS) {
150
+ lastWatchRenewalAt = Date.now();
151
+ try {
152
+ await renewAllWatches();
153
+ } catch (err) {
154
+ console.error("[mail-jobs] renewAllWatches failed:", err);
155
+ }
156
+ }
157
+ },
158
+ {
159
+ intervalMs: INTERVAL_MS,
160
+ timeoutMs: TICK_ABORT_MS,
161
+ leading: false,
162
+ onError: (err) =>
163
+ console.error("[mail-jobs] tick exceeded time budget:", err),
164
+ },
165
+ );
147
166
  };
@@ -7,6 +7,7 @@ import { AssistantRuntimeProvider, useLocalRuntime, useThreadRuntime, useThread,
7
7
  import { CompositeAttachmentAdapter } from "@assistant-ui/react";
8
8
  import { IconArrowUp, IconClock, IconMessage, IconX, IconPlayerStopFilled, IconTerminal, IconAlertTriangle, IconChevronDown, IconRefresh, } from "@tabler/icons-react";
9
9
  import { useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect, forwardRef, useImperativeHandle, } from "react";
10
+ import { createPollEngine } from "../shared/poll-engine.js";
10
11
  import { clearPendingTurnIfMatches, clearActiveRunIfMatches, getPendingTurn, getActiveRunActivityTool, getActiveRun, resolveReconnectAfterSeq, setActiveRun, updateActiveRunActivity, updateActiveRunSeq, } from "./active-run-state.js";
11
12
  import { activeRunLooksAlive, createAgentChatAdapter, } from "./agent-chat-adapter.js";
12
13
  import { appendAgentChatContextToMessage, formatAgentChatContextItemsForPrompt, getAgentChatContextState, isAgentChatSubmitCancelled, normalizeAgentChatContextItem, publishAgentChatContextItems, refreshAgentChatContext, reportAgentChatSubmitResult, subscribeAgentChatContext, } from "./agent-chat.js";
@@ -98,6 +99,13 @@ export function createUserMessageRunConfig(references, requestMode, recoveryActi
98
99
  return options;
99
100
  }
100
101
  const PENDING_SELECTION_KEY = "pending-selection-context";
102
+ // Bounds an in-flight poll fetch so its own boolean in-flight guard is
103
+ // guaranteed to release even if the server never responds. Mirrors
104
+ // use-db-sync.ts's getPollAbortMs.
105
+ const POLL_ABORT_MIN_MS = 10_000;
106
+ function getPollAbortMs(interval) {
107
+ return Math.max(POLL_ABORT_MIN_MS, interval * 4);
108
+ }
101
109
  const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
102
110
  const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
103
111
  const BACKGROUND_ACTIVE_RUN_STUCK_THRESHOLD_MS = 13 * 60_000;
@@ -1836,7 +1844,11 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
1836
1844
  }
1837
1845
  return repo;
1838
1846
  }, [threadRuntime]);
1839
- const refreshThreadFromServer = useCallback(async () => {
1847
+ // Accepts an optional external `signal` so poll loops (e.g. the reconnect
1848
+ // thread-poll engine below) can cancel this fetch via their own timeout
1849
+ // instead of racing a second, separately-constructed AbortController.
1850
+ // Callers with no signal of their own keep the internal fallback timeout.
1851
+ const refreshThreadFromServer = useCallback(async (signal) => {
1840
1852
  if (loadHistoryRepository) {
1841
1853
  try {
1842
1854
  const repo = await loadHistoryRepository();
@@ -1845,13 +1857,21 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
1845
1857
  return importThreadData(repo);
1846
1858
  }
1847
1859
  catch {
1860
+ // coercion-ok: callers treat null as "keep the thread already
1861
+ // rendered", the same handling an absent repo needs.
1848
1862
  return null;
1849
1863
  }
1850
1864
  }
1851
1865
  if (!threadId)
1852
1866
  return null;
1867
+ const ownAbort = !signal && typeof AbortController !== "undefined"
1868
+ ? new AbortController()
1869
+ : null;
1870
+ const ownAbortTimer = ownAbort
1871
+ ? setTimeout(() => ownAbort.abort(), getPollAbortMs(2000))
1872
+ : null;
1853
1873
  try {
1854
- const refreshRes = await fetch(`${apiUrl}/threads/${encodeURIComponent(threadId)}`);
1874
+ const refreshRes = await fetch(`${apiUrl}/threads/${encodeURIComponent(threadId)}`, { signal: signal ?? ownAbort?.signal });
1855
1875
  if (!refreshRes.ok)
1856
1876
  return null;
1857
1877
  const refreshData = await refreshRes.json();
@@ -1860,8 +1880,14 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
1860
1880
  return importThreadData(refreshData.threadData);
1861
1881
  }
1862
1882
  catch {
1883
+ // coercion-ok: an aborted or failed refresh keeps the thread already
1884
+ // rendered; the next poll tick retries.
1863
1885
  return null;
1864
1886
  }
1887
+ finally {
1888
+ if (ownAbortTimer)
1889
+ clearTimeout(ownAbortTimer);
1890
+ }
1865
1891
  }, [apiUrl, importThreadData, loadHistoryRepository, threadId]);
1866
1892
  const exportCleanThreadRepo = useCallback(() => ensureMessageMetadata(normalizeThreadRepository(threadRuntime.export())), [threadRuntime]);
1867
1893
  const exportPersistableThreadRepo = useCallback(() => withLastAssistantRunDuration(exportCleanThreadRepo(), showRunningInUI ? null : lastChatRunDurationMs), [exportCleanThreadRepo, lastChatRunDurationMs, showRunningInUI]);
@@ -2026,37 +2052,38 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
2026
2052
  reconnectAbortRef.current = abortCtrl;
2027
2053
  let reconnectTerminalReason = null;
2028
2054
  const reconnectStuckThresholdMs = activeRunStuckThresholdMs(runInfo);
2029
- const watchdog = setInterval(async () => {
2030
- try {
2031
- const res = await fetch(`${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`);
2032
- if (!res.ok) {
2033
- abortCtrl.abort();
2034
- clearInterval(watchdog);
2035
- return;
2036
- }
2037
- const info = (await res.json());
2038
- // SINGLE-READER OWNERSHIP: if the adapter runtime came alive while
2039
- // this reader was attached (user sent a message, adapter adopted the
2040
- // thread's run), this reader is now the duplicate — kill it. The
2041
- // false→true runtime-transition effect also unwinds the UI state;
2042
- // this covers the reader itself.
2043
- if (isRuntimeRunningRef.current) {
2044
- abortCtrl.abort();
2045
- clearInterval(watchdog);
2046
- return;
2047
- }
2048
- if (isReplayableTerminalRun(info)) {
2049
- return;
2050
- }
2051
- if (info.status !== "running" || activeRunLooksStale(info)) {
2052
- abortCtrl.abort();
2053
- clearInterval(watchdog);
2054
- }
2055
+ // This lives inside a useCallback, not component render, so it can't
2056
+ // use the usePollLoop hook (Rules of Hooks) — createPollEngine gives
2057
+ // the same never-overlaps/never-stalls guarantees imperatively.
2058
+ const watchdog = createPollEngine(async (signal) => {
2059
+ if (document.hidden)
2060
+ return;
2061
+ const res = await fetch(`${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`, { signal });
2062
+ if (!res.ok) {
2063
+ abortCtrl.abort();
2064
+ watchdog.stop();
2065
+ return;
2055
2066
  }
2056
- catch {
2057
- // Network blip — keep polling.
2067
+ const info = (await res.json());
2068
+ // SINGLE-READER OWNERSHIP: if the adapter runtime came alive while
2069
+ // this reader was attached (user sent a message, adapter adopted the
2070
+ // thread's run), this reader is now the duplicate — kill it. The
2071
+ // false→true runtime-transition effect also unwinds the UI state;
2072
+ // this covers the reader itself.
2073
+ if (isRuntimeRunningRef.current) {
2074
+ abortCtrl.abort();
2075
+ watchdog.stop();
2076
+ return;
2058
2077
  }
2059
- }, 1000);
2078
+ if (isReplayableTerminalRun(info)) {
2079
+ return;
2080
+ }
2081
+ if (info.status !== "running" || activeRunLooksStale(info)) {
2082
+ abortCtrl.abort();
2083
+ watchdog.stop();
2084
+ }
2085
+ }, { intervalMs: 1000 });
2086
+ watchdog.start();
2060
2087
  let reconnectTimedOut = false;
2061
2088
  // Idle deadline, NOT a total-duration cap. A long-but-healthy run (image
2062
2089
  // generation emits `activity` heartbeats every few seconds) keeps
@@ -2082,7 +2109,7 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
2082
2109
  }
2083
2110
  reconnectTimedOut = true;
2084
2111
  abortCtrl.abort();
2085
- clearInterval(watchdog);
2112
+ watchdog.stop();
2086
2113
  clearInterval(idleCheck);
2087
2114
  }, 1000);
2088
2115
  const streamReconnect = async () => {
@@ -2119,18 +2146,23 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
2119
2146
  return "unknown";
2120
2147
  }
2121
2148
  };
2122
- const threadPollInterval = afterSeq > 0
2123
- ? window.setInterval(() => {
2149
+ const threadPollEngine = afterSeq > 0
2150
+ ? createPollEngine(async (signal) => {
2151
+ if (document.hidden)
2152
+ return;
2124
2153
  if (reconnectRunIdRef.current !== runId)
2125
2154
  return;
2126
- // Adapter took over mid-poll — skip imports that would race the
2127
- // live stream and flicker tool cards back to older snapshots.
2128
- if (isRuntimeRunningRef.current || isAutoResumingRef.current) {
2155
+ // Adapter took over mid-poll — skip imports that would race
2156
+ // the live stream and flicker tool cards back to older
2157
+ // snapshots.
2158
+ if (isRuntimeRunningRef.current ||
2159
+ isAutoResumingRef.current) {
2129
2160
  return;
2130
2161
  }
2131
- void refreshThreadFromServer();
2132
- }, 2000)
2162
+ await refreshThreadFromServer(signal);
2163
+ }, { intervalMs: 2000, leading: false })
2133
2164
  : undefined;
2165
+ threadPollEngine?.start();
2134
2166
  try {
2135
2167
  const content = [];
2136
2168
  latestContent = content;
@@ -2213,10 +2245,8 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
2213
2245
  }
2214
2246
  }
2215
2247
  finally {
2216
- if (threadPollInterval !== undefined) {
2217
- window.clearInterval(threadPollInterval);
2218
- }
2219
- clearInterval(watchdog);
2248
+ threadPollEngine?.stop();
2249
+ watchdog.stop();
2220
2250
  clearInterval(idleCheck);
2221
2251
  }
2222
2252
  // A newer reader, live adapter, stop action, or component unmount took
@@ -66,5 +66,7 @@ export type MultiTabAssistantChatProps = Omit<AssistantChatProps, "tabId" | "thr
66
66
  scope?: ChatThreadScope | null;
67
67
  /** @deprecated Scope context is now rendered in the composer. */
68
68
  showScopeBadge?: boolean;
69
+ /** Cadence for hydrating agent-team sub-agent tab status. Default: 3000. */
70
+ agentTeamPollMs?: number;
69
71
  };
70
- export declare function MultiTabAssistantChat({ showTabBar, renderHeader, renderOverlay, contentHidden, apiUrl, storageKey, restoreActiveThread, browserTabId, threadUrlSync, scope, ...props }: MultiTabAssistantChatProps): React.JSX.Element;
72
+ export declare function MultiTabAssistantChat({ showTabBar, renderHeader, renderOverlay, contentHidden, apiUrl, storageKey, restoreActiveThread, browserTabId, threadUrlSync, scope, agentTeamPollMs, ...props }: MultiTabAssistantChatProps): React.JSX.Element;