@open-mercato/ui 0.10.1-develop.7618.1.ba9531c18e → 0.10.1-develop.7622.1.0584ba2a04

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.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { createLogger } from "@open-mercato/shared/lib/logger";
4
- import { apiCallOrThrow } from "../../backend/utils/apiCall.js";
4
+ import { apiCall } from "../../backend/utils/apiCall.js";
5
5
  const logger = createLogger("ui").child({ component: "useAiPendingActionPolling" });
6
6
  const TERMINAL_STATUSES = [
7
7
  "confirmed",
@@ -9,29 +9,47 @@ const TERMINAL_STATUSES = [
9
9
  "failed",
10
10
  "expired"
11
11
  ];
12
+ const TERMINAL_HTTP_ERROR_CODES = ["not_found", "unauthorized", "forbidden"];
13
+ const MAX_BACKOFF_MS = 6e4;
14
+ const MAX_CONSECUTIVE_FAILURES = 5;
12
15
  function isTerminal(status) {
13
16
  if (!status) return false;
14
17
  return TERMINAL_STATUSES.includes(status);
15
18
  }
19
+ function httpStatusToErrorCode(status) {
20
+ if (status === 404) return "not_found";
21
+ if (status === 401) return "unauthorized";
22
+ if (status === 403) return "forbidden";
23
+ return null;
24
+ }
25
+ function isHiddenDocument() {
26
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
27
+ }
16
28
  async function fetchPendingAction(pendingActionId, endpoint) {
17
29
  const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`;
18
- const call = await apiCallOrThrow(url, { method: "GET" });
30
+ const call = await apiCall(url, { method: "GET" });
19
31
  const body = call.result;
32
+ if (!call.ok) {
33
+ const code = httpStatusToErrorCode(call.status) ?? body?.code ?? "request_failed";
34
+ const message = body?.error ?? `Request failed with status ${call.status}.`;
35
+ return { ok: false, pendingAction: null, error: { code, message } };
36
+ }
20
37
  if (body && typeof body === "object") {
21
38
  if (body.pendingAction) {
22
- return { pendingAction: body.pendingAction, error: null };
39
+ return { ok: true, pendingAction: body.pendingAction, error: null };
23
40
  }
24
41
  if (typeof body.id === "string" && typeof body.status === "string") {
25
- return { pendingAction: body, error: null };
42
+ return { ok: true, pendingAction: body, error: null };
26
43
  }
27
44
  if (body.error) {
28
45
  return {
46
+ ok: true,
29
47
  pendingAction: null,
30
48
  error: { message: body.error, code: body.code }
31
49
  };
32
50
  }
33
51
  }
34
- return { pendingAction: null, error: null };
52
+ return { ok: true, pendingAction: null, error: null };
35
53
  }
36
54
  function useAiPendingActionPolling(options) {
37
55
  const {
@@ -46,6 +64,12 @@ function useAiPendingActionPolling(options) {
46
64
  const mountedRef = React.useRef(true);
47
65
  const timerRef = React.useRef(null);
48
66
  const statusRef = React.useRef(null);
67
+ const terminalErrorCodeRef = React.useRef(null);
68
+ const consecutiveFailuresRef = React.useRef(0);
69
+ const stoppedRef = React.useRef(false);
70
+ const hiddenRef = React.useRef(isHiddenDocument());
71
+ const scheduleNextRef = React.useRef(() => {
72
+ });
49
73
  const clearTimer = React.useCallback(() => {
50
74
  if (timerRef.current) {
51
75
  clearTimeout(timerRef.current);
@@ -54,26 +78,46 @@ function useAiPendingActionPolling(options) {
54
78
  }, []);
55
79
  const refresh = React.useCallback(async () => {
56
80
  if (!pendingActionId) return null;
81
+ let pendingAction = null;
57
82
  try {
58
83
  const result = await fetchPendingAction(pendingActionId, endpoint);
59
84
  if (!mountedRef.current) return result.pendingAction ?? null;
60
- if (result.error) {
61
- setError(result.error);
85
+ pendingAction = result.pendingAction ?? null;
86
+ if (result.ok) {
87
+ consecutiveFailuresRef.current = 0;
88
+ stoppedRef.current = false;
89
+ terminalErrorCodeRef.current = null;
90
+ setError(result.error ?? null);
91
+ if (result.pendingAction) {
92
+ setAction(result.pendingAction);
93
+ statusRef.current = result.pendingAction.status;
94
+ }
62
95
  } else {
63
- setError(null);
96
+ setError(result.error ?? null);
97
+ const code = result.error?.code;
98
+ if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {
99
+ terminalErrorCodeRef.current = code;
100
+ } else {
101
+ consecutiveFailuresRef.current += 1;
102
+ if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
103
+ stoppedRef.current = true;
104
+ }
105
+ }
64
106
  }
65
- if (result.pendingAction) {
66
- setAction(result.pendingAction);
67
- statusRef.current = result.pendingAction.status;
68
- }
69
- return result.pendingAction;
70
107
  } catch (err) {
71
108
  logger.error("Failed to load the pending AI action", { err, pendingActionId });
72
109
  if (!mountedRef.current) return null;
73
110
  const message = err instanceof Error ? err.message : "Failed to load pending action.";
74
111
  setError({ message });
75
- return null;
112
+ consecutiveFailuresRef.current += 1;
113
+ if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
114
+ stoppedRef.current = true;
115
+ }
116
+ }
117
+ if (mountedRef.current) {
118
+ scheduleNextRef.current();
76
119
  }
120
+ return pendingAction;
77
121
  }, [endpoint, pendingActionId]);
78
122
  const scheduleNext = React.useCallback(() => {
79
123
  clearTimer();
@@ -86,19 +130,50 @@ function useAiPendingActionPolling(options) {
86
130
  setIsPolling(false);
87
131
  return;
88
132
  }
133
+ if (terminalErrorCodeRef.current) {
134
+ setIsPolling(false);
135
+ return;
136
+ }
137
+ if (stoppedRef.current) {
138
+ setIsPolling(false);
139
+ return;
140
+ }
141
+ if (hiddenRef.current) {
142
+ return;
143
+ }
89
144
  setIsPolling(true);
90
- timerRef.current = setTimeout(async () => {
91
- try {
92
- await refresh();
93
- } catch (err) {
94
- logger.error("Polling the pending AI action failed unexpectedly", { err });
95
- }
96
- scheduleNext();
97
- }, intervalMs);
145
+ const delay = consecutiveFailuresRef.current > 0 ? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS) : intervalMs;
146
+ timerRef.current = setTimeout(() => {
147
+ void refresh();
148
+ }, delay);
98
149
  }, [clearTimer, disabled, intervalMs, refresh]);
150
+ React.useEffect(() => {
151
+ scheduleNextRef.current = scheduleNext;
152
+ }, [scheduleNext]);
153
+ React.useEffect(() => {
154
+ if (disabled) return;
155
+ const handleVisibilityChange = () => {
156
+ const wasHidden = hiddenRef.current;
157
+ hiddenRef.current = isHiddenDocument();
158
+ if (!wasHidden && hiddenRef.current) {
159
+ clearTimer();
160
+ return;
161
+ }
162
+ if (wasHidden && !hiddenRef.current && mountedRef.current) {
163
+ void refresh();
164
+ }
165
+ };
166
+ document.addEventListener("visibilitychange", handleVisibilityChange);
167
+ return () => {
168
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
169
+ };
170
+ }, [clearTimer, disabled, refresh]);
99
171
  React.useEffect(() => {
100
172
  mountedRef.current = true;
101
173
  statusRef.current = null;
174
+ terminalErrorCodeRef.current = null;
175
+ consecutiveFailuresRef.current = 0;
176
+ stoppedRef.current = false;
102
177
  if (disabled) {
103
178
  setIsPolling(false);
104
179
  return () => {
@@ -107,13 +182,7 @@ function useAiPendingActionPolling(options) {
107
182
  };
108
183
  }
109
184
  setIsPolling(true);
110
- void refresh().catch((err) => {
111
- logger.error("Loading the pending AI action failed unexpectedly", { err });
112
- return null;
113
- }).then(() => {
114
- if (!mountedRef.current) return;
115
- scheduleNext();
116
- });
185
+ void refresh();
117
186
  return () => {
118
187
  mountedRef.current = false;
119
188
  clearTimer();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/ai/parts/useAiPendingActionPolling.ts"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { apiCallOrThrow } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\nconst logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n if (result.error) {\n setError(result.error)\n } else {\n setError(null)\n }\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n return result.pendingAction\n } catch (err) {\n logger.error('Failed to load the pending AI action', { err, pendingActionId })\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n return null\n }\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n setIsPolling(true)\n timerRef.current = setTimeout(async () => {\n try {\n await refresh()\n } catch (err) {\n logger.error('Polling the pending AI action failed unexpectedly', { err })\n }\n scheduleNext()\n }, intervalMs)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee. The catch\n // runs before the scheduling step so a rejected first fetch degrades to a\n // logged error instead of silently killing the polling loop.\n void refresh()\n .catch((err) => {\n logger.error('Loading the pending AI action failed unexpectedly', { err })\n return null\n })\n .then(() => {\n if (!mountedRef.current) return\n scheduleNext()\n })\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { apiCall } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\nconst logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - A 404/401/403 from the endpoint is also terminal \u2014 the row is gone or\n * unreachable and retrying cannot help (issue #7550: without this, every\n * card whose row disappeared polled every 3s forever, in every tab).\n * - Any other failure (5xx, network error) backs off exponentially (capped\n * at 60s) and gives up after 5 consecutive failures.\n * - Polling pauses while the tab is hidden (`document.visibilityState`) and\n * resumes on `visibilitychange`.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop. `refresh()` also restarts a\n * stopped loop (backoff-exhausted or resumed-from-hidden) without\n * creating a second timer chain.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nconst TERMINAL_HTTP_ERROR_CODES: ReadonlyArray<string> = ['not_found', 'unauthorized', 'forbidden']\n\nconst MAX_BACKOFF_MS = 60_000\nconst MAX_CONSECUTIVE_FAILURES = 5\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nfunction httpStatusToErrorCode(status: number): string | null {\n if (status === 404) return 'not_found'\n if (status === 401) return 'unauthorized'\n if (status === 403) return 'forbidden'\n return null\n}\n\nfunction isHiddenDocument(): boolean {\n return typeof document !== 'undefined' && document.visibilityState === 'hidden'\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n ok: boolean\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCall<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n if (!call.ok) {\n const code = httpStatusToErrorCode(call.status) ?? body?.code ?? 'request_failed'\n const message = body?.error ?? `Request failed with status ${call.status}.`\n return { ok: false, pendingAction: null, error: { code, message } }\n }\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { ok: true, pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { ok: true, pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n ok: true,\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { ok: true, pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n const terminalErrorCodeRef = React.useRef<string | null>(null)\n const consecutiveFailuresRef = React.useRef(0)\n const stoppedRef = React.useRef(false)\n const hiddenRef = React.useRef(isHiddenDocument())\n const scheduleNextRef = React.useRef<() => void>(() => {})\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n let pendingAction: AiPendingActionCardAction | null = null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n pendingAction = result.pendingAction ?? null\n if (result.ok) {\n consecutiveFailuresRef.current = 0\n stoppedRef.current = false\n terminalErrorCodeRef.current = null\n setError(result.error ?? null)\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n } else {\n setError(result.error ?? null)\n const code = result.error?.code\n if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {\n terminalErrorCodeRef.current = code\n } else {\n consecutiveFailuresRef.current += 1\n if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {\n stoppedRef.current = true\n }\n }\n }\n } catch (err) {\n logger.error('Failed to load the pending AI action', { err, pendingActionId })\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n consecutiveFailuresRef.current += 1\n if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {\n stoppedRef.current = true\n }\n }\n if (mountedRef.current) {\n scheduleNextRef.current()\n }\n return pendingAction\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n if (terminalErrorCodeRef.current) {\n setIsPolling(false)\n return\n }\n if (stoppedRef.current) {\n setIsPolling(false)\n return\n }\n if (hiddenRef.current) {\n // Paused while the tab is hidden. `visibilitychange` resumes via refresh().\n return\n }\n setIsPolling(true)\n const delay =\n consecutiveFailuresRef.current > 0\n ? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS)\n : intervalMs\n timerRef.current = setTimeout(() => {\n void refresh()\n }, delay)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n scheduleNextRef.current = scheduleNext\n }, [scheduleNext])\n\n React.useEffect(() => {\n if (disabled) return\n const handleVisibilityChange = () => {\n const wasHidden = hiddenRef.current\n hiddenRef.current = isHiddenDocument()\n if (!wasHidden && hiddenRef.current) {\n // Cancel any already-scheduled poll immediately \u2014 do not wait for\n // it to fire before honoring the pause.\n clearTimer()\n return\n }\n if (wasHidden && !hiddenRef.current && mountedRef.current) {\n // Resume immediately rather than waiting out a stale backoff delay.\n void refresh()\n }\n }\n document.addEventListener('visibilitychange', handleVisibilityChange)\n return () => {\n document.removeEventListener('visibilitychange', handleVisibilityChange)\n }\n }, [clearTimer, disabled, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n terminalErrorCodeRef.current = null\n consecutiveFailuresRef.current = 0\n stoppedRef.current = false\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee. `refresh`\n // schedules the next poll itself once it settles.\n void refresh()\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.10.1-develop.7618.1.ba9531c18e",
3
+ "version": "0.10.1-develop.7622.1.0584ba2a04",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.10.1-develop.7618.1.ba9531c18e",
158
+ "@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.10.1-develop.7618.1.ba9531c18e",
165
+ "@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.1",
168
168
  "@testing-library/react": "^16.3.3",
@@ -6,7 +6,7 @@ import * as React from 'react'
6
6
  import { act, renderHook, waitFor } from '@testing-library/react'
7
7
 
8
8
  jest.mock('../../../backend/utils/apiCall', () => ({
9
- apiCallOrThrow: jest.fn(),
9
+ apiCall: jest.fn(),
10
10
  }))
11
11
 
12
12
  jest.mock('@open-mercato/shared/lib/logger', () => {
@@ -22,7 +22,7 @@ jest.mock('@open-mercato/shared/lib/logger', () => {
22
22
  })
23
23
 
24
24
  import { createLogger } from '@open-mercato/shared/lib/logger'
25
- import { apiCallOrThrow } from '../../../backend/utils/apiCall'
25
+ import { apiCall } from '../../../backend/utils/apiCall'
26
26
  import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
27
27
  import type { AiPendingActionCardAction, AiPendingActionCardStatus } from '../types'
28
28
 
@@ -51,8 +51,8 @@ function makeAction(
51
51
  }
52
52
  }
53
53
 
54
- function mockResponse(action: AiPendingActionCardAction) {
55
- ;(apiCallOrThrow as jest.Mock).mockImplementation(async () => ({
54
+ function mockOkResponse(action: AiPendingActionCardAction) {
55
+ ;(apiCall as jest.Mock).mockImplementation(async () => ({
56
56
  ok: true,
57
57
  status: 200,
58
58
  result: { pendingAction: action },
@@ -61,17 +61,35 @@ function mockResponse(action: AiPendingActionCardAction) {
61
61
  }))
62
62
  }
63
63
 
64
+ function mockStatusResponse(status: number) {
65
+ return {
66
+ ok: false,
67
+ status,
68
+ result: null,
69
+ response: {},
70
+ cacheStatus: null,
71
+ }
72
+ }
73
+
74
+ function setDocumentVisibility(state: 'visible' | 'hidden') {
75
+ Object.defineProperty(document, 'visibilityState', {
76
+ configurable: true,
77
+ get: () => state,
78
+ })
79
+ }
80
+
64
81
  const loggerError = createLogger('ui').error as jest.Mock
65
82
 
66
83
  describe('useAiPendingActionPolling', () => {
67
84
  beforeEach(() => {
68
- ;(apiCallOrThrow as jest.Mock).mockReset()
85
+ ;(apiCall as jest.Mock).mockReset()
69
86
  loggerError.mockClear()
87
+ setDocumentVisibility('visible')
70
88
  })
71
89
 
72
90
  it('fetches on mount and continues polling while status is pending', async () => {
73
91
  let status: AiPendingActionCardStatus = 'pending'
74
- ;(apiCallOrThrow as jest.Mock).mockImplementation(async () => ({
92
+ ;(apiCall as jest.Mock).mockImplementation(async () => ({
75
93
  ok: true,
76
94
  status: 200,
77
95
  result: { pendingAction: makeAction({ status }) },
@@ -90,7 +108,7 @@ describe('useAiPendingActionPolling', () => {
90
108
  await Promise.resolve()
91
109
  await Promise.resolve()
92
110
  })
93
- expect(apiCallOrThrow).toHaveBeenCalledTimes(1)
111
+ expect(apiCall).toHaveBeenCalledTimes(1)
94
112
  expect(result.current.status).toBe('pending')
95
113
 
96
114
  await act(async () => {
@@ -98,7 +116,7 @@ describe('useAiPendingActionPolling', () => {
98
116
  await Promise.resolve()
99
117
  await Promise.resolve()
100
118
  })
101
- expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(2)
119
+ expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(2)
102
120
 
103
121
  status = 'confirmed'
104
122
  await act(async () => {
@@ -107,13 +125,13 @@ describe('useAiPendingActionPolling', () => {
107
125
  await Promise.resolve()
108
126
  })
109
127
  expect(result.current.status).toBe('confirmed')
110
- const callsAfterTerminal = (apiCallOrThrow as jest.Mock).mock.calls.length
128
+ const callsAfterTerminal = (apiCall as jest.Mock).mock.calls.length
111
129
 
112
130
  await act(async () => {
113
131
  jest.advanceTimersByTime(5000)
114
132
  await Promise.resolve()
115
133
  })
116
- expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBe(callsAfterTerminal)
134
+ expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterTerminal)
117
135
  expect(result.current.isPolling).toBe(false)
118
136
 
119
137
  unmount()
@@ -123,14 +141,14 @@ describe('useAiPendingActionPolling', () => {
123
141
  })
124
142
 
125
143
  it('refresh() force-fetches and updates state', async () => {
126
- mockResponse(makeAction({ status: 'pending' }))
144
+ mockOkResponse(makeAction({ status: 'pending' }))
127
145
  const { result } = renderHook(() =>
128
146
  useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 99999 }),
129
147
  )
130
148
 
131
149
  await waitFor(() => expect(result.current.action?.status).toBe('pending'))
132
150
 
133
- ;(apiCallOrThrow as jest.Mock).mockImplementationOnce(async () => ({
151
+ ;(apiCall as jest.Mock).mockImplementationOnce(async () => ({
134
152
  ok: true,
135
153
  status: 200,
136
154
  result: { pendingAction: makeAction({ status: 'confirmed' }) },
@@ -148,26 +166,26 @@ describe('useAiPendingActionPolling', () => {
148
166
  it('clears outstanding timers when unmounted mid-poll', async () => {
149
167
  jest.useFakeTimers()
150
168
  try {
151
- mockResponse(makeAction({ status: 'pending' }))
169
+ mockOkResponse(makeAction({ status: 'pending' }))
152
170
  const { unmount } = renderHook(() =>
153
171
  useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
154
172
  )
155
- await waitFor(() => expect(apiCallOrThrow).toHaveBeenCalledTimes(1))
173
+ await waitFor(() => expect(apiCall).toHaveBeenCalledTimes(1))
156
174
  unmount()
157
- const callsAtUnmount = (apiCallOrThrow as jest.Mock).mock.calls.length
175
+ const callsAtUnmount = (apiCall as jest.Mock).mock.calls.length
158
176
  // Advance well past several intervals — no more calls must be fired.
159
177
  await act(async () => {
160
178
  jest.advanceTimersByTime(10_000)
161
179
  })
162
- expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBe(callsAtUnmount)
180
+ expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAtUnmount)
163
181
  } finally {
164
182
  jest.useRealTimers()
165
183
  }
166
184
  })
167
185
 
168
- it('logs a failed fetch and keeps polling until it recovers', async () => {
186
+ it('logs a failed fetch and keeps polling (with backoff) until it recovers', async () => {
169
187
  const failure = new Error('[internal] pending action endpoint unreachable')
170
- ;(apiCallOrThrow as jest.Mock)
188
+ ;(apiCall as jest.Mock)
171
189
  .mockImplementationOnce(async () => {
172
190
  throw failure
173
191
  })
@@ -196,8 +214,9 @@ describe('useAiPendingActionPolling', () => {
196
214
  expect(result.current.error?.message).toBe(failure.message)
197
215
  expect(result.current.action).toBeNull()
198
216
 
217
+ // First retry after a failure backs off to intervalMs * 2.
199
218
  await act(async () => {
200
- jest.advanceTimersByTime(1000)
219
+ jest.advanceTimersByTime(2000)
201
220
  await Promise.resolve()
202
221
  await Promise.resolve()
203
222
  })
@@ -209,4 +228,152 @@ describe('useAiPendingActionPolling', () => {
209
228
  jest.useRealTimers()
210
229
  }
211
230
  })
231
+
232
+ it('stops polling on a 404 and reports error.code=not_found', async () => {
233
+ ;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(404))
234
+
235
+ jest.useFakeTimers()
236
+ try {
237
+ const { result, unmount } = renderHook(() =>
238
+ useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
239
+ )
240
+
241
+ await act(async () => {
242
+ await Promise.resolve()
243
+ await Promise.resolve()
244
+ })
245
+ expect(result.current.error?.code).toBe('not_found')
246
+ expect(result.current.isPolling).toBe(false)
247
+ const callsAfterNotFound = (apiCall as jest.Mock).mock.calls.length
248
+
249
+ await act(async () => {
250
+ jest.advanceTimersByTime(60_000)
251
+ await Promise.resolve()
252
+ })
253
+ expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterNotFound)
254
+
255
+ unmount()
256
+ } finally {
257
+ jest.useRealTimers()
258
+ }
259
+ })
260
+
261
+ it('backs off exponentially on repeated 500s and stops after 5 consecutive failures', async () => {
262
+ ;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(500))
263
+
264
+ jest.useFakeTimers()
265
+ try {
266
+ const { result, unmount } = renderHook(() =>
267
+ useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
268
+ )
269
+
270
+ // Initial fetch (failure #1), then drive through failures #2-#5 by
271
+ // advancing past each capped backoff delay.
272
+ for (let i = 0; i < 5; i += 1) {
273
+ await act(async () => {
274
+ jest.advanceTimersByTime(60_000)
275
+ await Promise.resolve()
276
+ await Promise.resolve()
277
+ })
278
+ }
279
+
280
+ expect(result.current.isPolling).toBe(false)
281
+ const callsAfterStop = (apiCall as jest.Mock).mock.calls.length
282
+ expect(callsAfterStop).toBeGreaterThanOrEqual(5)
283
+
284
+ await act(async () => {
285
+ jest.advanceTimersByTime(60_000)
286
+ await Promise.resolve()
287
+ })
288
+ expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterStop)
289
+
290
+ unmount()
291
+ } finally {
292
+ jest.useRealTimers()
293
+ }
294
+ })
295
+
296
+ it('refresh() restarts a stopped loop', async () => {
297
+ ;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(500))
298
+
299
+ jest.useFakeTimers()
300
+ try {
301
+ const { result, unmount } = renderHook(() =>
302
+ useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
303
+ )
304
+
305
+ for (let i = 0; i < 5; i += 1) {
306
+ await act(async () => {
307
+ jest.advanceTimersByTime(60_000)
308
+ await Promise.resolve()
309
+ await Promise.resolve()
310
+ })
311
+ }
312
+ expect(result.current.isPolling).toBe(false)
313
+
314
+ ;(apiCall as jest.Mock).mockImplementation(async () => ({
315
+ ok: true,
316
+ status: 200,
317
+ result: { pendingAction: makeAction({ status: 'pending' }) },
318
+ response: {},
319
+ cacheStatus: null,
320
+ }))
321
+
322
+ await act(async () => {
323
+ await result.current.refresh()
324
+ })
325
+ expect(result.current.action?.status).toBe('pending')
326
+ expect(result.current.isPolling).toBe(true)
327
+
328
+ const callsAfterRestart = (apiCall as jest.Mock).mock.calls.length
329
+ await act(async () => {
330
+ jest.advanceTimersByTime(1000)
331
+ await Promise.resolve()
332
+ await Promise.resolve()
333
+ })
334
+ expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThan(callsAfterRestart)
335
+
336
+ unmount()
337
+ } finally {
338
+ jest.useRealTimers()
339
+ }
340
+ })
341
+
342
+ it('pauses polling while the tab is hidden and resumes on visibilitychange', async () => {
343
+ mockOkResponse(makeAction({ status: 'pending' }))
344
+
345
+ jest.useFakeTimers()
346
+ try {
347
+ const { unmount } = renderHook(() =>
348
+ useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
349
+ )
350
+
351
+ await act(async () => {
352
+ await Promise.resolve()
353
+ await Promise.resolve()
354
+ })
355
+ const callsBeforeHidden = (apiCall as jest.Mock).mock.calls.length
356
+
357
+ setDocumentVisibility('hidden')
358
+ document.dispatchEvent(new Event('visibilitychange'))
359
+
360
+ await act(async () => {
361
+ jest.advanceTimersByTime(10_000)
362
+ await Promise.resolve()
363
+ })
364
+ expect((apiCall as jest.Mock).mock.calls.length).toBe(callsBeforeHidden)
365
+
366
+ setDocumentVisibility('visible')
367
+ await act(async () => {
368
+ document.dispatchEvent(new Event('visibilitychange'))
369
+ await Promise.resolve()
370
+ await Promise.resolve()
371
+ })
372
+ expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThan(callsBeforeHidden)
373
+
374
+ unmount()
375
+ } finally {
376
+ jest.useRealTimers()
377
+ }
378
+ })
212
379
  })
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
- import { apiCallOrThrow } from '../../backend/utils/apiCall'
5
+ import { apiCall } from '../../backend/utils/apiCall'
6
6
  import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
7
7
 
8
8
  const logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })
@@ -18,8 +18,17 @@ const logger = createLogger('ui').child({ component: 'useAiPendingActionPolling'
18
18
  * - Poll every 3 seconds while the status is non-terminal
19
19
  * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,
20
20
  * `failed`, `expired`) stop polling.
21
+ * - A 404/401/403 from the endpoint is also terminal — the row is gone or
22
+ * unreachable and retrying cannot help (issue #7550: without this, every
23
+ * card whose row disappeared polled every 3s forever, in every tab).
24
+ * - Any other failure (5xx, network error) backs off exponentially (capped
25
+ * at 60s) and gives up after 5 consecutive failures.
26
+ * - Polling pauses while the tab is hidden (`document.visibilityState`) and
27
+ * resumes on `visibilitychange`.
21
28
  * - Expose a `refresh()` force-fetch helper the confirmation card uses after
22
- * a confirm POST races with the polling loop.
29
+ * a confirm POST races with the polling loop. `refresh()` also restarts a
30
+ * stopped loop (backoff-exhausted or resumed-from-hidden) without
31
+ * creating a second timer chain.
23
32
  *
24
33
  * The hook owns a single `setInterval`/`setTimeout` — unmounting clears
25
34
  * every outstanding timer. This is required for the Jest fake-timers
@@ -33,11 +42,27 @@ const TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [
33
42
  'expired',
34
43
  ]
35
44
 
45
+ const TERMINAL_HTTP_ERROR_CODES: ReadonlyArray<string> = ['not_found', 'unauthorized', 'forbidden']
46
+
47
+ const MAX_BACKOFF_MS = 60_000
48
+ const MAX_CONSECUTIVE_FAILURES = 5
49
+
36
50
  function isTerminal(status: AiPendingActionCardStatus | null): boolean {
37
51
  if (!status) return false
38
52
  return TERMINAL_STATUSES.includes(status)
39
53
  }
40
54
 
55
+ function httpStatusToErrorCode(status: number): string | null {
56
+ if (status === 404) return 'not_found'
57
+ if (status === 401) return 'unauthorized'
58
+ if (status === 403) return 'forbidden'
59
+ return null
60
+ }
61
+
62
+ function isHiddenDocument(): boolean {
63
+ return typeof document !== 'undefined' && document.visibilityState === 'hidden'
64
+ }
65
+
41
66
  export interface UseAiPendingActionPollingOptions {
42
67
  pendingActionId: string
43
68
  /**
@@ -57,6 +82,7 @@ export interface UseAiPendingActionPollingOptions {
57
82
  }
58
83
 
59
84
  export interface AiPendingActionFetchResult {
85
+ ok: boolean
60
86
  pendingAction: AiPendingActionCardAction | null
61
87
  error?: { code?: string; message: string } | null
62
88
  }
@@ -74,7 +100,7 @@ async function fetchPendingAction(
74
100
  endpoint: string,
75
101
  ): Promise<AiPendingActionFetchResult> {
76
102
  const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`
77
- const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })
103
+ const call = await apiCall<unknown>(url, { method: 'GET' })
78
104
  const body = call.result as
79
105
  | (Partial<AiPendingActionCardAction> & {
80
106
  pendingAction?: AiPendingActionCardAction
@@ -84,6 +110,12 @@ async function fetchPendingAction(
84
110
  | null
85
111
  | undefined
86
112
 
113
+ if (!call.ok) {
114
+ const code = httpStatusToErrorCode(call.status) ?? body?.code ?? 'request_failed'
115
+ const message = body?.error ?? `Request failed with status ${call.status}.`
116
+ return { ok: false, pendingAction: null, error: { code, message } }
117
+ }
118
+
87
119
  // The GET route returns the bare action object (`serializePendingActionForClient(row)`),
88
120
  // but earlier client code expected it under a `pendingAction` envelope.
89
121
  // Accept BOTH shapes so the cards work whether the dispatcher wraps or
@@ -92,22 +124,23 @@ async function fetchPendingAction(
92
124
  // MutationPreviewCard / ConfirmationCard / MutationResultCard.
93
125
  if (body && typeof body === 'object') {
94
126
  if (body.pendingAction) {
95
- return { pendingAction: body.pendingAction, error: null }
127
+ return { ok: true, pendingAction: body.pendingAction, error: null }
96
128
  }
97
129
  if (
98
130
  typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&
99
131
  typeof (body as Partial<AiPendingActionCardAction>).status === 'string'
100
132
  ) {
101
- return { pendingAction: body as AiPendingActionCardAction, error: null }
133
+ return { ok: true, pendingAction: body as AiPendingActionCardAction, error: null }
102
134
  }
103
135
  if (body.error) {
104
136
  return {
137
+ ok: true,
105
138
  pendingAction: null,
106
139
  error: { message: body.error, code: body.code },
107
140
  }
108
141
  }
109
142
  }
110
- return { pendingAction: null, error: null }
143
+ return { ok: true, pendingAction: null, error: null }
111
144
  }
112
145
 
113
146
  export function useAiPendingActionPolling(
@@ -127,6 +160,11 @@ export function useAiPendingActionPolling(
127
160
  const mountedRef = React.useRef(true)
128
161
  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
129
162
  const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)
163
+ const terminalErrorCodeRef = React.useRef<string | null>(null)
164
+ const consecutiveFailuresRef = React.useRef(0)
165
+ const stoppedRef = React.useRef(false)
166
+ const hiddenRef = React.useRef(isHiddenDocument())
167
+ const scheduleNextRef = React.useRef<() => void>(() => {})
130
168
 
131
169
  const clearTimer = React.useCallback(() => {
132
170
  if (timerRef.current) {
@@ -137,26 +175,46 @@ export function useAiPendingActionPolling(
137
175
 
138
176
  const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {
139
177
  if (!pendingActionId) return null
178
+ let pendingAction: AiPendingActionCardAction | null = null
140
179
  try {
141
180
  const result = await fetchPendingAction(pendingActionId, endpoint)
142
181
  if (!mountedRef.current) return result.pendingAction ?? null
143
- if (result.error) {
144
- setError(result.error)
182
+ pendingAction = result.pendingAction ?? null
183
+ if (result.ok) {
184
+ consecutiveFailuresRef.current = 0
185
+ stoppedRef.current = false
186
+ terminalErrorCodeRef.current = null
187
+ setError(result.error ?? null)
188
+ if (result.pendingAction) {
189
+ setAction(result.pendingAction)
190
+ statusRef.current = result.pendingAction.status
191
+ }
145
192
  } else {
146
- setError(null)
193
+ setError(result.error ?? null)
194
+ const code = result.error?.code
195
+ if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {
196
+ terminalErrorCodeRef.current = code
197
+ } else {
198
+ consecutiveFailuresRef.current += 1
199
+ if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
200
+ stoppedRef.current = true
201
+ }
202
+ }
147
203
  }
148
- if (result.pendingAction) {
149
- setAction(result.pendingAction)
150
- statusRef.current = result.pendingAction.status
151
- }
152
- return result.pendingAction
153
204
  } catch (err) {
154
205
  logger.error('Failed to load the pending AI action', { err, pendingActionId })
155
206
  if (!mountedRef.current) return null
156
207
  const message = err instanceof Error ? err.message : 'Failed to load pending action.'
157
208
  setError({ message })
158
- return null
209
+ consecutiveFailuresRef.current += 1
210
+ if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
211
+ stoppedRef.current = true
212
+ }
159
213
  }
214
+ if (mountedRef.current) {
215
+ scheduleNextRef.current()
216
+ }
217
+ return pendingAction
160
218
  }, [endpoint, pendingActionId])
161
219
 
162
220
  const scheduleNext = React.useCallback(() => {
@@ -170,20 +228,60 @@ export function useAiPendingActionPolling(
170
228
  setIsPolling(false)
171
229
  return
172
230
  }
231
+ if (terminalErrorCodeRef.current) {
232
+ setIsPolling(false)
233
+ return
234
+ }
235
+ if (stoppedRef.current) {
236
+ setIsPolling(false)
237
+ return
238
+ }
239
+ if (hiddenRef.current) {
240
+ // Paused while the tab is hidden. `visibilitychange` resumes via refresh().
241
+ return
242
+ }
173
243
  setIsPolling(true)
174
- timerRef.current = setTimeout(async () => {
175
- try {
176
- await refresh()
177
- } catch (err) {
178
- logger.error('Polling the pending AI action failed unexpectedly', { err })
179
- }
180
- scheduleNext()
181
- }, intervalMs)
244
+ const delay =
245
+ consecutiveFailuresRef.current > 0
246
+ ? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS)
247
+ : intervalMs
248
+ timerRef.current = setTimeout(() => {
249
+ void refresh()
250
+ }, delay)
182
251
  }, [clearTimer, disabled, intervalMs, refresh])
183
252
 
253
+ React.useEffect(() => {
254
+ scheduleNextRef.current = scheduleNext
255
+ }, [scheduleNext])
256
+
257
+ React.useEffect(() => {
258
+ if (disabled) return
259
+ const handleVisibilityChange = () => {
260
+ const wasHidden = hiddenRef.current
261
+ hiddenRef.current = isHiddenDocument()
262
+ if (!wasHidden && hiddenRef.current) {
263
+ // Cancel any already-scheduled poll immediately — do not wait for
264
+ // it to fire before honoring the pause.
265
+ clearTimer()
266
+ return
267
+ }
268
+ if (wasHidden && !hiddenRef.current && mountedRef.current) {
269
+ // Resume immediately rather than waiting out a stale backoff delay.
270
+ void refresh()
271
+ }
272
+ }
273
+ document.addEventListener('visibilitychange', handleVisibilityChange)
274
+ return () => {
275
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
276
+ }
277
+ }, [clearTimer, disabled, refresh])
278
+
184
279
  React.useEffect(() => {
185
280
  mountedRef.current = true
186
281
  statusRef.current = null
282
+ terminalErrorCodeRef.current = null
283
+ consecutiveFailuresRef.current = 0
284
+ stoppedRef.current = false
187
285
  if (disabled) {
188
286
  setIsPolling(false)
189
287
  return () => {
@@ -192,18 +290,9 @@ export function useAiPendingActionPolling(
192
290
  }
193
291
  }
194
292
  setIsPolling(true)
195
- // Always fetch on mount — the "reconnect behavior" guarantee. The catch
196
- // runs before the scheduling step so a rejected first fetch degrades to a
197
- // logged error instead of silently killing the polling loop.
293
+ // Always fetch on mount — the "reconnect behavior" guarantee. `refresh`
294
+ // schedules the next poll itself once it settles.
198
295
  void refresh()
199
- .catch((err) => {
200
- logger.error('Loading the pending AI action failed unexpectedly', { err })
201
- return null
202
- })
203
- .then(() => {
204
- if (!mountedRef.current) return
205
- scheduleNext()
206
- })
207
296
  return () => {
208
297
  mountedRef.current = false
209
298
  clearTimer()