@chrysb/alphaclaw 0.9.9 → 0.9.11

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 (43) hide show
  1. package/lib/public/dist/app.bundle.js +1773 -1747
  2. package/lib/public/js/components/agents-tab/agent-overview/use-model-card.js +4 -5
  3. package/lib/public/js/components/agents-tab/agent-pairing-section.js +11 -5
  4. package/lib/public/js/components/cron-tab/cron-helpers.js +13 -1
  5. package/lib/public/js/components/cron-tab/use-cron-tab.js +4 -3
  6. package/lib/public/js/components/general/index.js +6 -1
  7. package/lib/public/js/components/general/use-general-tab.js +17 -20
  8. package/lib/public/js/components/models-tab/index.js +5 -1
  9. package/lib/public/js/components/models-tab/model-picker.js +52 -0
  10. package/lib/public/js/components/onboarding/use-welcome-pairing.js +4 -1
  11. package/lib/public/js/components/pairings.js +75 -4
  12. package/lib/public/js/components/welcome/use-welcome.js +37 -8
  13. package/lib/public/js/hooks/usePolling.js +46 -13
  14. package/lib/public/js/lib/model-config.js +6 -2
  15. package/lib/server/agents/channels.js +53 -9
  16. package/lib/server/commands.js +4 -1
  17. package/lib/server/constants.js +14 -3
  18. package/lib/server/cost-utils.js +9 -0
  19. package/lib/server/cron-service.js +12 -1
  20. package/lib/server/db/doctor/index.js +9 -0
  21. package/lib/server/db/usage/index.js +13 -0
  22. package/lib/server/db/watchdog/index.js +13 -1
  23. package/lib/server/db/webhooks/index.js +13 -1
  24. package/lib/server/gateway.js +119 -8
  25. package/lib/server/init/register-server-routes.js +3 -0
  26. package/lib/server/init/runtime-init.js +2 -0
  27. package/lib/server/internal-files-migration.js +11 -1
  28. package/lib/server/model-catalog-bootstrap.json +3193 -0
  29. package/lib/server/model-catalog-cache.js +124 -32
  30. package/lib/server/onboarding/github.js +79 -2
  31. package/lib/server/onboarding/index.js +2 -9
  32. package/lib/server/onboarding/openclaw.js +18 -4
  33. package/lib/server/openclaw-runtime-env.js +55 -0
  34. package/lib/server/openclaw-version.js +2 -1
  35. package/lib/server/routes/models.js +28 -0
  36. package/lib/server/routes/pairings.js +106 -15
  37. package/lib/server/usage-tracker-config.js +28 -3
  38. package/lib/server/utils/command-output.js +11 -0
  39. package/lib/server.js +4 -0
  40. package/lib/setup/gitignore +2 -0
  41. package/package.json +2 -2
  42. package/patches/openclaw+2026.4.23.patch +63 -0
  43. package/patches/openclaw+2026.4.15.patch +0 -13
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
2
2
  import { useModels } from "../../models-tab/use-models.js";
3
3
  import {
4
4
  buildProviderHasAuth,
5
+ buildSyntheticModelEntry,
5
6
  getModelCatalogProvider,
6
7
  getModelsTabAuthProvider,
7
8
  getProviderSortIndex,
@@ -51,10 +52,8 @@ export const useModelCard = ({
51
52
  Object.keys(configuredModels || {})
52
53
  .map(
53
54
  (modelKey) =>
54
- resolveCatalogModel(catalog, modelKey) || {
55
- key: modelKey,
56
- label: modelKey,
57
- },
55
+ resolveCatalogModel(catalog, modelKey) ||
56
+ buildSyntheticModelEntry(modelKey),
58
57
  )
59
58
  .filter((model) => {
60
59
  const provider = getModelsTabAuthProvider(model.key);
@@ -75,7 +74,7 @@ export const useModelCard = ({
75
74
  const effectiveModelEntry = useMemo(
76
75
  () =>
77
76
  resolveCatalogModel(catalog, effectiveModel) ||
78
- (effectiveModel ? { key: effectiveModel, label: effectiveModel } : null),
77
+ (effectiveModel ? buildSyntheticModelEntry(effectiveModel) : null),
79
78
  [catalog, effectiveModel],
80
79
  );
81
80
 
@@ -241,12 +241,14 @@ export const AgentPairingSection = ({ agent = {} }) => {
241
241
  },
242
242
  3000,
243
243
  {
244
- enabled: hasUnpaired && ownedAccounts.length > 0,
244
+ enabled: ownedAccounts.length > 0,
245
245
  cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`,
246
+ dedupeInFlight: true,
246
247
  },
247
248
  );
248
249
 
249
250
  const pending = pairingsPoll.data || [];
251
+ const showPairings = hasUnpaired || pending.length > 0 || pairingStatusRefreshing;
250
252
 
251
253
  const refreshAfterPairingAction = useCallback(() => {
252
254
  setPairingStatusRefreshing(true);
@@ -262,7 +264,7 @@ export const AgentPairingSection = ({ agent = {} }) => {
262
264
  }
263
265
  pairingDelayedRefreshTimerRefs.current = [];
264
266
  const refresh = () => {
265
- pairingsPoll.refresh();
267
+ pairingsPoll.refresh({ force: true });
266
268
  loadBindings();
267
269
  announcePairingsChanged(agentId);
268
270
  };
@@ -273,10 +275,12 @@ export const AgentPairingSection = ({ agent = {} }) => {
273
275
 
274
276
  const handleApprove = async (id, channel, accountId = "") => {
275
277
  try {
276
- await approvePairing(id, channel, accountId);
278
+ const result = await approvePairing(id, channel, accountId);
279
+ if (!result.ok) throw new Error(result.error || "Could not approve pairing");
277
280
  refreshAfterPairingAction();
278
281
  } catch (err) {
279
282
  showToast(err.message || "Could not approve pairing", "error");
283
+ throw err;
280
284
  }
281
285
  };
282
286
 
@@ -286,6 +290,7 @@ export const AgentPairingSection = ({ agent = {} }) => {
286
290
  refreshAfterPairingAction();
287
291
  } catch (err) {
288
292
  showToast(err.message || "Could not reject pairing", "error");
293
+ throw err;
289
294
  }
290
295
  };
291
296
 
@@ -298,13 +303,14 @@ export const AgentPairingSection = ({ agent = {} }) => {
298
303
  `;
299
304
  }
300
305
 
301
- if (!hasUnpaired) return null;
306
+ if (!showPairings) return null;
302
307
 
303
308
  return html`
304
309
  <${Pairings}
305
310
  pending=${pending}
306
311
  channels=${ownedChannelsStatus}
307
- visible=${hasUnpaired}
312
+ visible=${showPairings}
313
+ pollingInFlight=${pairingsPoll.isPolling}
308
314
  statusRefreshing=${pairingStatusRefreshing}
309
315
  onApprove=${handleApprove}
310
316
  onReject=${handleReject}
@@ -7,6 +7,18 @@ import {
7
7
 
8
8
  export const kAllCronJobsRouteKey = "__all__";
9
9
 
10
+ export const readCronJobPrompt = (job = {}) => {
11
+ const payload = job?.payload && typeof job.payload === "object" ? job.payload : {};
12
+ const kind = String(payload?.kind || "").trim();
13
+ if (kind === "systemEvent" && typeof payload.text === "string") {
14
+ return payload.text;
15
+ }
16
+ if (kind === "agentTurn" && typeof payload.message === "string") {
17
+ return payload.message;
18
+ }
19
+ return "";
20
+ };
21
+
10
22
  const kWeekdayLabelByCronValue = {
11
23
  "0": "Sun",
12
24
  "1": "Mon",
@@ -388,7 +400,7 @@ export const buildCronOptimizationWarnings = (jobs = [], bulkRunsByJobId = {}) =
388
400
  const warnings = [];
389
401
  jobs.forEach((job) => {
390
402
  const jobId = String(job?.id || "");
391
- const prompt = String(job?.payload?.message || "").toLowerCase();
403
+ const prompt = readCronJobPrompt(job).toLowerCase();
392
404
  const deliveryMode = String(job?.delivery?.mode || "").toLowerCase();
393
405
  if (
394
406
  deliveryMode === "none" &&
@@ -22,7 +22,7 @@ import {
22
22
  } from "../../lib/api.js";
23
23
  import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
24
24
  import { showToast } from "../toast.js";
25
- import { kAllCronJobsRouteKey } from "./cron-helpers.js";
25
+ import { kAllCronJobsRouteKey, readCronJobPrompt } from "./cron-helpers.js";
26
26
 
27
27
  const kDefaultListPanelWidthPx = 372;
28
28
  const kListPanelMinWidthPx = 220;
@@ -178,6 +178,7 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
178
178
  () => jobs.find((job) => String(job?.id || "") === selectedJobId) || null,
179
179
  [jobs, selectedJobId],
180
180
  );
181
+ const selectedJobPrompt = readCronJobPrompt(selectedJob);
181
182
 
182
183
  useEffect(() => {
183
184
  if (!selectedJobId) {
@@ -186,11 +187,11 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
186
187
  setRoutingDraft(kRoutingDefaults);
187
188
  return;
188
189
  }
189
- const prompt = String(selectedJob?.payload?.message || "");
190
+ const prompt = selectedJobPrompt;
190
191
  setPromptValue(prompt);
191
192
  setSavedPromptValue(prompt);
192
193
  setRoutingDraft(readRoutingDraftFromJob(selectedJob));
193
- }, [selectedJobId, selectedJob?.payload?.message]);
194
+ }, [selectedJobId, selectedJobPrompt]);
194
195
 
195
196
  useEffect(() => {
196
197
  if (!selectedJobId) return;
@@ -62,6 +62,10 @@ export const GeneralTab = ({
62
62
  Array.isArray(state.pending) &&
63
63
  state.pending.length === 0 &&
64
64
  hasWhatsAppAwaitingPairing;
65
+ const showPairings =
66
+ state.hasUnpaired ||
67
+ (Array.isArray(state.pending) && state.pending.length > 0) ||
68
+ state.pairingStatusRefreshing;
65
69
 
66
70
  return html`
67
71
  <div class="space-y-4">
@@ -117,7 +121,8 @@ export const GeneralTab = ({
117
121
  <${Pairings}
118
122
  pending=${state.pending}
119
123
  channels=${state.channels}
120
- visible=${state.hasUnpaired}
124
+ visible=${showPairings}
125
+ pollingInFlight=${state.pairingsPolling}
121
126
  statusRefreshing=${state.pairingStatusRefreshing}
122
127
  onApprove=${actions.handleApprove}
123
128
  onReject=${actions.handleReject}
@@ -56,6 +56,9 @@ export const useGeneralTab = ({
56
56
  }
57
57
  return info.status !== "paired";
58
58
  });
59
+ const hasConfiguredPairingChannel = ALL_CHANNELS.some((channel) =>
60
+ Boolean(channels?.[channel]),
61
+ );
59
62
 
60
63
  const pairingsPoll = usePolling(
61
64
  async () => {
@@ -64,8 +67,9 @@ export const useGeneralTab = ({
64
67
  },
65
68
  3000,
66
69
  {
67
- enabled: hasUnpaired && gatewayStatus === "running",
70
+ enabled: hasConfiguredPairingChannel && gatewayStatus === "running",
68
71
  cacheKey: "/api/pairings",
72
+ dedupeInFlight: true,
69
73
  },
70
74
  );
71
75
  const pending = pairingsPoll.data || [];
@@ -86,25 +90,10 @@ export const useGeneralTab = ({
86
90
  );
87
91
  const devicePending = devicePoll.data || [];
88
92
 
89
- useEffect(() => {
90
- if (!isActive) return;
91
- pairingsPoll.refresh();
92
- if (shouldPollDevices) {
93
- devicePoll.refresh();
94
- }
95
- }, [
96
- devicePoll.refresh,
97
- isActive,
98
- onRefreshStatuses,
99
- pairingsPoll.refresh,
100
- devicePollingEnabled,
101
- shouldPollDevices,
102
- ]);
103
-
104
93
  useEffect(() => {
105
94
  if (!restartSignal || !isActive) return;
106
95
  onRefreshStatuses();
107
- pairingsPoll.refresh();
96
+ pairingsPoll.refresh({ force: true });
108
97
  if (shouldPollDevices) {
109
98
  devicePoll.refresh();
110
99
  }
@@ -164,7 +153,7 @@ export const useGeneralTab = ({
164
153
  pairingRefreshTimerRef.current = null;
165
154
  }, 2800);
166
155
  onRefreshStatuses();
167
- pairingsPoll.refresh();
156
+ pairingsPoll.refresh({ force: true });
168
157
  setTimeout(() => {
169
158
  onRefreshStatuses();
170
159
  pairingsPoll.refresh();
@@ -208,8 +197,14 @@ export const useGeneralTab = ({
208
197
  };
209
198
 
210
199
  const handleApprove = async (id, channel, accountId = "") => {
211
- await approvePairing(id, channel, accountId);
212
- refreshAfterPairingAction();
200
+ try {
201
+ const result = await approvePairing(id, channel, accountId);
202
+ if (!result.ok) throw new Error(result.error || "Could not approve pairing");
203
+ refreshAfterPairingAction();
204
+ } catch (err) {
205
+ showToast(err.message || "Could not approve pairing", "error");
206
+ throw err;
207
+ }
213
208
  };
214
209
 
215
210
  const handleReject = async (id, channel, accountId = "") => {
@@ -218,6 +213,7 @@ export const useGeneralTab = ({
218
213
  refreshAfterPairingAction();
219
214
  } catch (err) {
220
215
  showToast(err.message || "Could not reject pairing", "error");
216
+ throw err;
221
217
  }
222
218
  };
223
219
 
@@ -276,6 +272,7 @@ export const useGeneralTab = ({
276
272
  hasUnpaired,
277
273
  openclawVersion,
278
274
  pending,
275
+ pairingsPolling: pairingsPoll.isPolling,
279
276
  pairingStatusRefreshing,
280
277
  repairingWatchdog,
281
278
  repo,
@@ -10,6 +10,7 @@ import { Badge } from "../badge.js";
10
10
  import { useModels } from "./use-models.js";
11
11
  import {
12
12
  buildProviderHasAuth,
13
+ buildSyntheticModelEntry,
13
14
  getModelCatalogProvider,
14
15
  getModelsTabAuthProvider,
15
16
  getProviderSortIndex,
@@ -111,12 +112,14 @@ export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false
111
112
  const configuredModelEntries = useMemo(
112
113
  () =>
113
114
  Object.keys(configuredModels).map((key) => {
114
- const catalogEntry = catalog.find((m) => m.key === key);
115
+ const catalogEntry =
116
+ catalog.find((m) => m.key === key) || buildSyntheticModelEntry(key);
115
117
  const provider = getModelsTabAuthProvider(key);
116
118
  const hasAuth = !!providerHasAuth[provider];
117
119
  return {
118
120
  key,
119
121
  label: catalogEntry?.label || key,
122
+ provider: catalogEntry?.provider || provider,
120
123
  isPrimary: key === primary,
121
124
  hasAuth,
122
125
  };
@@ -216,6 +219,7 @@ export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false
216
219
  <${SearchableModelPicker}
217
220
  options=${pickerModels}
218
221
  popularModels=${popularPickerModels}
222
+ configuredOptions=${configuredModelEntries}
219
223
  placeholder="Add model..."
220
224
  onSelect=${(modelKey) => {
221
225
  addModel(modelKey);
@@ -36,6 +36,33 @@ const formatProviderSectionLabel = (provider) =>
36
36
 
37
37
  const normalizeSearch = (value) => String(value || "").trim().toLowerCase();
38
38
 
39
+ const titleCaseToken = (value) => {
40
+ const normalized = String(value || "").trim().toLowerCase();
41
+ if (!normalized) return "";
42
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);
43
+ };
44
+
45
+ const formatAnthropicModelLabel = (modelId) => {
46
+ const match = /^claude-(opus|sonnet|haiku)-(\d+)[-.](\d+)$/i.exec(
47
+ String(modelId || "").trim(),
48
+ );
49
+ if (!match) return "";
50
+ return `Claude ${titleCaseToken(match[1])} ${match[2]}.${match[3]}`;
51
+ };
52
+
53
+ export const buildSyntheticModelEntry = (modelKey) => {
54
+ const key = String(modelKey || "").trim();
55
+ const provider = getModelProvider(key);
56
+ const modelId = key.includes("/") ? key.split("/").slice(1).join("/") : key;
57
+ const anthropicLabel =
58
+ provider === "anthropic" ? formatAnthropicModelLabel(modelId) : "";
59
+ return {
60
+ key,
61
+ provider,
62
+ label: anthropicLabel || key,
63
+ };
64
+ };
65
+
39
66
  export const getModelDisplayLabel = (model) => model?.featuredLabel || model?.label || model?.key;
40
67
 
41
68
  const buildModelSearchText = (model) =>
@@ -67,6 +94,7 @@ export const buildProviderHasAuth = ({
67
94
  export const SearchableModelPicker = ({
68
95
  options = [],
69
96
  popularModels = [],
97
+ configuredOptions = [],
70
98
  placeholder = "Add model...",
71
99
  onSelect = () => {},
72
100
  disabled = false,
@@ -84,6 +112,15 @@ export const SearchableModelPicker = ({
84
112
  : options,
85
113
  [options, normalizedQuery],
86
114
  );
115
+ const matchingConfiguredOptions = useMemo(
116
+ () =>
117
+ normalizedQuery
118
+ ? configuredOptions.filter((option) =>
119
+ buildModelSearchText(option).includes(normalizedQuery),
120
+ )
121
+ : [],
122
+ [configuredOptions, normalizedQuery],
123
+ );
87
124
  const groupedOptions = useMemo(() => {
88
125
  const groups = [];
89
126
  const showPopularGroup = !normalizedQuery;
@@ -198,6 +235,21 @@ export const SearchableModelPicker = ({
198
235
  </div>
199
236
  `,
200
237
  )
238
+ : matchingConfiguredOptions.length > 0
239
+ ? html`
240
+ <div class="px-3 py-3 text-xs text-fg-muted">
241
+ ${matchingConfiguredOptions.length === 1
242
+ ? html`
243
+ Already added above:
244
+ <span class="text-body">
245
+ ${getModelDisplayLabel(
246
+ matchingConfiguredOptions[0],
247
+ )}
248
+ </span>
249
+ `
250
+ : `${matchingConfiguredOptions.length} matching models are already added above.`}
251
+ </div>
252
+ `
201
253
  : html`
202
254
  <div class="px-3 py-3 text-xs text-fg-muted">
203
255
  No models match that search.
@@ -20,7 +20,10 @@ export const useWelcomePairing = ({
20
20
  return allPending.filter((p) => p.channel === selectedPairingChannel);
21
21
  },
22
22
  1000,
23
- { enabled: isPairingStep && !!selectedPairingChannel },
23
+ {
24
+ enabled: isPairingStep && !!selectedPairingChannel,
25
+ dedupeInFlight: true,
26
+ },
24
27
  );
25
28
  const pairingChannels = pairingStatusPoll.data?.channels || {};
26
29
  const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel);
@@ -1,7 +1,8 @@
1
1
  import { h } from 'preact';
2
- import { useState } from 'preact/hooks';
2
+ import { useEffect, useState } from 'preact/hooks';
3
3
  import htm from 'htm';
4
4
  import { ActionButton } from './action-button.js';
5
+ import { LoadingSpinner } from './loading-spinner.js';
5
6
  const html = htm.bind(h);
6
7
 
7
8
  export const PairingRow = ({ p, onApprove, onReject }) => {
@@ -66,6 +67,13 @@ const ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp'];
66
67
 
67
68
  const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
68
69
 
70
+ const getPairingKey = (p) => {
71
+ const channel = String(p?.channel || "").trim().toLowerCase();
72
+ const accountId = String(p?.accountId || "").trim() || "default";
73
+ const id = String(p?.id || p?.code || "").trim();
74
+ return channel && id ? `${channel}\u0000${accountId}\u0000${id}` : "";
75
+ };
76
+
69
77
  export function Pairings({
70
78
  pending,
71
79
  channels,
@@ -73,7 +81,52 @@ export function Pairings({
73
81
  onApprove,
74
82
  onReject,
75
83
  statusRefreshing = false,
84
+ pollingInFlight = false,
76
85
  }) {
86
+ const [hiddenPairingKeys, setHiddenPairingKeys] = useState(() => new Set());
87
+ const pendingList = Array.isArray(pending) ? pending : [];
88
+
89
+ useEffect(() => {
90
+ setHiddenPairingKeys((current) => {
91
+ if (current.size === 0) return current;
92
+ const pendingKeys = new Set(
93
+ pendingList.map(getPairingKey).filter(Boolean),
94
+ );
95
+ const next = new Set();
96
+ for (const key of current) {
97
+ if (pendingKeys.has(key)) {
98
+ next.add(key);
99
+ }
100
+ }
101
+ return next.size === current.size ? current : next;
102
+ });
103
+ }, [pending]);
104
+
105
+ const hidePairing = (p) => {
106
+ const key = getPairingKey(p);
107
+ if (!key) return;
108
+ setHiddenPairingKeys((current) => {
109
+ if (current.has(key)) return current;
110
+ const next = new Set(current);
111
+ next.add(key);
112
+ return next;
113
+ });
114
+ };
115
+
116
+ const handleApprove = async (p) => {
117
+ await onApprove(p.id, p.channel, p.accountId);
118
+ hidePairing(p);
119
+ };
120
+
121
+ const handleReject = async (p) => {
122
+ await onReject(p.id, p.channel, p.accountId);
123
+ hidePairing(p);
124
+ };
125
+
126
+ const visiblePending = pendingList.filter(
127
+ (p) => !hiddenPairingKeys.has(getPairingKey(p)),
128
+ );
129
+
77
130
  if (!visible) return null;
78
131
 
79
132
  const unpaired = ALL_CHANNELS
@@ -95,12 +148,30 @@ export function Pairings({
95
148
  ? unpaired.join(' or ')
96
149
  : unpaired.slice(0, -1).join(', ') + ', or ' + unpaired[unpaired.length - 1];
97
150
 
151
+ if (unpaired.length === 0 && visiblePending.length === 0) return null;
152
+
98
153
  return html`
99
154
  <div class="bg-surface border border-border rounded-xl p-4">
100
- <h2 class="card-label mb-3">Pending Pairings</h2>
101
- ${pending.length > 0
155
+ <div class="flex items-center justify-between gap-3 mb-3">
156
+ <h2 class="card-label">Pending Pairings</h2>
157
+ ${pollingInFlight
158
+ ? html`
159
+ <div class="inline-flex items-center text-fg-muted" aria-label="Pairings refresh in progress">
160
+ <${LoadingSpinner} className="h-3.5 w-3.5 text-fg-muted" />
161
+ </div>
162
+ `
163
+ : null}
164
+ </div>
165
+ ${visiblePending.length > 0
102
166
  ? html`<div>
103
- ${pending.map(p => html`<${PairingRow} key=${p.id} p=${p} onApprove=${onApprove} onReject=${onReject} />`)}
167
+ ${visiblePending.map((p) => html`
168
+ <${PairingRow}
169
+ key=${getPairingKey(p) || p.id}
170
+ p=${p}
171
+ onApprove=${() => handleApprove(p)}
172
+ onReject=${() => handleReject(p)}
173
+ />
174
+ `)}
104
175
  </div>`
105
176
  : statusRefreshing
106
177
  ? html`<div class="text-center py-4 space-y-2">
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "preact/hooks";
1
+ import { useCallback, useEffect, useState } from "preact/hooks";
2
2
  import {
3
3
  runOnboard,
4
4
  verifyGithubOnboardingRepo,
@@ -7,6 +7,7 @@ import {
7
7
  fetchModels,
8
8
  } from "../../lib/api.js";
9
9
  import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
10
+ import { usePolling } from "../../hooks/usePolling.js";
10
11
  import {
11
12
  getModelProvider,
12
13
  getAuthProviderFromModelProvider,
@@ -17,7 +18,9 @@ import {
17
18
  import {
18
19
  getInitialOnboardingModelKey,
19
20
  getModelCatalogModels,
21
+ isModelCatalogRefreshing,
20
22
  kModelCatalogCacheKey,
23
+ kModelCatalogPollIntervalMs,
21
24
  preloadModelCatalog,
22
25
  } from "../../lib/model-catalog.js";
23
26
  import {
@@ -100,6 +103,7 @@ export const useWelcome = ({ onComplete }) => {
100
103
  const [models, setModels] = useState([]);
101
104
  const [modelsLoading, setModelsLoading] = useState(true);
102
105
  const [modelsError, setModelsError] = useState(null);
106
+ const [modelsRefreshing, setModelsRefreshing] = useState(false);
103
107
  const [showAllModels, setShowAllModels] = useState(false);
104
108
  const [loading, setLoading] = useState(false);
105
109
  const [githubStepLoading, setGithubStepLoading] = useState(false);
@@ -129,6 +133,11 @@ export const useWelcome = ({ onComplete }) => {
129
133
  const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {
130
134
  maxAgeMs: 30000,
131
135
  });
136
+ const modelsPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, {
137
+ enabled: modelsRefreshing,
138
+ pauseWhenHidden: true,
139
+ cacheKey: kModelCatalogCacheKey,
140
+ });
132
141
 
133
142
  useEffect(() => {
134
143
  // Warm the real catalog immediately so the AI step usually opens ready.
@@ -157,11 +166,21 @@ export const useWelcome = ({ onComplete }) => {
157
166
  }));
158
167
  };
159
168
 
160
- useEffect(() => {
161
- const list = getModelCatalogModels(modelsFetchState.data);
162
- if (!modelsFetchState.data) return;
169
+ const applyModelCatalog = useCallback((payload) => {
170
+ const list = getModelCatalogModels(payload);
171
+ if (!payload) return;
172
+ const isRefreshing = isModelCatalogRefreshing(payload);
173
+ const isFallbackRefresh =
174
+ String(payload?.source || "") === "fallback" && isRefreshing;
163
175
  setModels(list);
164
- setModelsError(list.length > 0 ? null : "No models found");
176
+ setModelsRefreshing(isRefreshing);
177
+ setModelsError(
178
+ list.length > 0
179
+ ? isFallbackRefresh
180
+ ? "Loading full model catalog..."
181
+ : null
182
+ : "No models found",
183
+ );
165
184
  const defaultModelKey = getInitialOnboardingModelKey({
166
185
  catalog: list,
167
186
  currentModelKey: vals.MODEL_KEY,
@@ -169,12 +188,22 @@ export const useWelcome = ({ onComplete }) => {
169
188
  if (!vals.MODEL_KEY && defaultModelKey) {
170
189
  setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey }));
171
190
  }
172
- }, [modelsFetchState.data, setVals, vals.MODEL_KEY]);
191
+ }, [setVals, vals.MODEL_KEY]);
192
+
193
+ useEffect(() => {
194
+ applyModelCatalog(modelsFetchState.data);
195
+ }, [applyModelCatalog, modelsFetchState.data]);
196
+
197
+ useEffect(() => {
198
+ applyModelCatalog(modelsPoll.data);
199
+ }, [applyModelCatalog, modelsPoll.data]);
173
200
 
174
201
  useEffect(() => {
175
202
  const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0;
176
- setModelsLoading(modelsFetchState.loading && !hasModels);
177
- }, [modelsFetchState.data, modelsFetchState.loading]);
203
+ setModelsLoading(
204
+ (modelsFetchState.loading || modelsPoll.isPolling) && !hasModels,
205
+ );
206
+ }, [modelsFetchState.data, modelsFetchState.loading, modelsPoll.isPolling]);
178
207
 
179
208
  useEffect(() => {
180
209
  if (!modelsFetchState.error) return;
@@ -8,6 +8,7 @@ export const usePolling = (
8
8
  enabled = true,
9
9
  pauseWhenHidden = true,
10
10
  cacheKey = "",
11
+ dedupeInFlight = false,
11
12
  } = {},
12
13
  ) => {
13
14
  const normalizedCacheKey = String(cacheKey || "");
@@ -15,23 +16,55 @@ export const usePolling = (
15
16
  normalizedCacheKey ? getCached(normalizedCacheKey) : null,
16
17
  );
17
18
  const [error, setError] = useState(null);
19
+ const [isPolling, setIsPolling] = useState(false);
18
20
  const fetcherRef = useRef(fetcher);
21
+ const inFlightRefreshRef = useRef(null);
22
+ const activeRefreshCountRef = useRef(0);
23
+ const nextRefreshIdRef = useRef(0);
24
+ const latestRefreshIdRef = useRef(0);
19
25
  fetcherRef.current = fetcher;
20
26
 
21
- const refresh = useCallback(async () => {
22
- try {
23
- const result = await fetcherRef.current();
24
- if (normalizedCacheKey) {
25
- setCached(normalizedCacheKey, result);
27
+ const refresh = useCallback(async ({ force = false } = {}) => {
28
+ if (dedupeInFlight && inFlightRefreshRef.current && !force) {
29
+ return inFlightRefreshRef.current;
30
+ }
31
+ const refreshId = nextRefreshIdRef.current + 1;
32
+ nextRefreshIdRef.current = refreshId;
33
+ latestRefreshIdRef.current = refreshId;
34
+ activeRefreshCountRef.current += 1;
35
+ setIsPolling(true);
36
+ const refreshPromise = Promise.resolve().then(async () => {
37
+ try {
38
+ const result = await fetcherRef.current();
39
+ if (latestRefreshIdRef.current === refreshId) {
40
+ if (normalizedCacheKey) {
41
+ setCached(normalizedCacheKey, result);
42
+ }
43
+ setData(result);
44
+ setError(null);
45
+ }
46
+ return result;
47
+ } catch (err) {
48
+ if (latestRefreshIdRef.current === refreshId) {
49
+ setError(err);
50
+ }
51
+ return null;
52
+ } finally {
53
+ activeRefreshCountRef.current = Math.max(
54
+ 0,
55
+ activeRefreshCountRef.current - 1,
56
+ );
57
+ setIsPolling(activeRefreshCountRef.current > 0);
58
+ if (inFlightRefreshRef.current === refreshPromise) {
59
+ inFlightRefreshRef.current = null;
60
+ }
26
61
  }
27
- setData(result);
28
- setError(null);
29
- return result;
30
- } catch (err) {
31
- setError(err);
32
- return null;
62
+ });
63
+ if (dedupeInFlight) {
64
+ inFlightRefreshRef.current = refreshPromise;
33
65
  }
34
- }, [normalizedCacheKey]);
66
+ return refreshPromise;
67
+ }, [dedupeInFlight, normalizedCacheKey]);
35
68
 
36
69
  useEffect(() => {
37
70
  if (!normalizedCacheKey) return;
@@ -63,5 +96,5 @@ export const usePolling = (
63
96
  document.removeEventListener("visibilitychange", handleVisibilityChange);
64
97
  }, [enabled, pauseWhenHidden, refresh]);
65
98
 
66
- return { data, error, refresh };
99
+ return { data, error, refresh, isPolling };
67
100
  };