@chrysb/alphaclaw 0.8.5 → 0.8.7-beta.0

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 (45) hide show
  1. package/bin/alphaclaw.js +56 -20
  2. package/lib/public/css/explorer.css +48 -0
  3. package/lib/public/css/shell.css +149 -0
  4. package/lib/public/css/tailwind.generated.css +1 -1
  5. package/lib/public/css/theme.css +265 -0
  6. package/lib/public/dist/app.bundle.js +2441 -2352
  7. package/lib/public/js/app.js +7 -0
  8. package/lib/public/js/components/gateway.js +6 -3
  9. package/lib/public/js/components/general/index.js +2 -0
  10. package/lib/public/js/components/icons.js +38 -0
  11. package/lib/public/js/components/models-tab/provider-auth-card.js +60 -49
  12. package/lib/public/js/components/models-tab/use-models.js +74 -9
  13. package/lib/public/js/components/models.js +52 -37
  14. package/lib/public/js/components/onboarding/use-welcome-codex.js +34 -24
  15. package/lib/public/js/components/onboarding/welcome-config.js +76 -10
  16. package/lib/public/js/components/onboarding/welcome-form-step.js +31 -11
  17. package/lib/public/js/components/onboarding/welcome-header.js +12 -14
  18. package/lib/public/js/components/onboarding/welcome-setup-step.js +3 -3
  19. package/lib/public/js/components/providers.js +53 -42
  20. package/lib/public/js/components/routes/general-route.js +2 -0
  21. package/lib/public/js/components/routes/watchdog-route.js +2 -0
  22. package/lib/public/js/components/sidebar.js +29 -8
  23. package/lib/public/js/components/theme-toggle.js +113 -0
  24. package/lib/public/js/components/update-modal-helpers.js +12 -0
  25. package/lib/public/js/components/update-modal.js +2 -1
  26. package/lib/public/js/components/watchdog-tab/index.js +2 -0
  27. package/lib/public/js/components/welcome/index.js +1 -2
  28. package/lib/public/js/components/welcome/use-welcome.js +153 -38
  29. package/lib/public/js/hooks/use-app-shell-controller.js +33 -9
  30. package/lib/public/js/lib/api.js +35 -0
  31. package/lib/public/js/lib/codex-oauth-window.js +22 -0
  32. package/lib/public/js/lib/model-catalog.js +20 -0
  33. package/lib/public/js/lib/storage-keys.js +1 -1
  34. package/lib/public/login.html +8 -4
  35. package/lib/public/setup.html +9 -0
  36. package/lib/server/alphaclaw-version.js +30 -127
  37. package/lib/server/db/webhooks/index.js +48 -8
  38. package/lib/server/model-catalog-cache.js +251 -0
  39. package/lib/server/openclaw-version.js +59 -130
  40. package/lib/server/pending-alphaclaw-update.js +71 -0
  41. package/lib/server/pending-openclaw-update.js +71 -0
  42. package/lib/server/routes/models.js +14 -23
  43. package/lib/server/routes/system.js +6 -1
  44. package/lib/server/routes/webhooks.js +12 -1
  45. package/package.json +1 -1
@@ -9,6 +9,7 @@ import {
9
9
  } from "wouter-preact";
10
10
  import { logout } from "./lib/api.js";
11
11
  import { Welcome } from "./components/welcome/index.js";
12
+ import { ThemeToggle } from "./components/theme-toggle.js";
12
13
  import { ToastContainer } from "./components/toast.js";
13
14
  import { GlobalRestartBanner } from "./components/global-restart-banner.js";
14
15
  import { LoadingSpinner } from "./components/loading-spinner.js";
@@ -160,6 +161,9 @@ const App = () => {
160
161
  class="min-h-screen flex flex-col items-center pt-12 pb-8 px-4"
161
162
  style="position: relative; z-index: 1"
162
163
  >
164
+ <div style="position: fixed; top: 16px; right: 16px; z-index: 50;">
165
+ <${ThemeToggle} />
166
+ </div>
163
167
  <${Welcome}
164
168
  onComplete=${controllerActions.handleOnboardingComplete}
165
169
  acVersion=${controllerState.acVersion}
@@ -198,6 +202,7 @@ const App = () => {
198
202
  onPreviewBrowseFile=${browseActions.handleBrowsePreviewFile}
199
203
  acHasUpdate=${controllerState.acHasUpdate}
200
204
  acLatest=${controllerState.acLatest}
205
+ acRestarting=${controllerState.acRestarting}
201
206
  acUpdating=${controllerState.acUpdating}
202
207
  onAcUpdate=${controllerActions.handleAcUpdate}
203
208
  agents=${agentsState.agents}
@@ -380,6 +385,7 @@ const App = () => {
380
385
  restartingGateway=${controllerState.restartingGateway}
381
386
  onRestartGateway=${controllerActions.handleGatewayRestart}
382
387
  restartSignal=${controllerState.gatewayRestartSignal}
388
+ openclawRestarting=${controllerState.openclawRestarting}
383
389
  openclawUpdateInProgress=${controllerState.openclawUpdateInProgress}
384
390
  onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete}
385
391
  onOpenclawUpdate=${controllerActions.handleOpenclawUpdate}
@@ -415,6 +421,7 @@ const App = () => {
415
421
  restartingGateway=${controllerState.restartingGateway}
416
422
  onRestartGateway=${controllerActions.handleGatewayRestart}
417
423
  restartSignal=${controllerState.gatewayRestartSignal}
424
+ openclawRestarting=${controllerState.openclawRestarting}
418
425
  openclawUpdateInProgress=${controllerState.openclawUpdateInProgress}
419
426
  onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete}
420
427
  onOpenclawUpdate=${controllerActions.handleOpenclawUpdate}
@@ -27,6 +27,7 @@ const VersionRow = ({
27
27
  fetchVersion,
28
28
  applyUpdate,
29
29
  updateInProgress = false,
30
+ updateLoadingLabel = "Updating...",
30
31
  onActionComplete = () => {},
31
32
  }) => {
32
33
  const [checking, setChecking] = useState(false);
@@ -236,7 +237,7 @@ const VersionRow = ({
236
237
  ? updateIdleLabel
237
238
  : "Check updates"}
238
239
  loadingLabel=${isUpdateActionActive
239
- ? "Updating..."
240
+ ? updateLoadingLabel
240
241
  : "Checking..."}
241
242
  className="hidden md:inline-flex"
242
243
  />
@@ -250,7 +251,7 @@ const VersionRow = ({
250
251
  ? updateIdleLabel
251
252
  : "Check updates"}
252
253
  loadingLabel=${isUpdateActionActive
253
- ? "Updating..."
254
+ ? updateLoadingLabel
254
255
  : "Checking..."}
255
256
  />
256
257
  `}
@@ -272,7 +273,7 @@ const VersionRow = ({
272
273
  loading=${updateButtonLoading}
273
274
  warning=${isUpdateActionActive}
274
275
  idleLabel=${updateIdleLabel}
275
- loadingLabel="Updating..."
276
+ loadingLabel=${updateLoadingLabel}
276
277
  className="flex-1 h-9 px-3"
277
278
  />
278
279
  </div>
@@ -299,6 +300,7 @@ export const Gateway = ({
299
300
  onOpenWatchdog,
300
301
  onRepair,
301
302
  repairing = false,
303
+ openclawRestarting = false,
302
304
  openclawUpdateInProgress = false,
303
305
  onOpenclawVersionActionComplete = () => {},
304
306
  onOpenclawUpdate = updateOpenclaw,
@@ -443,6 +445,7 @@ export const Gateway = ({
443
445
  fetchVersion=${fetchOpenclawVersion}
444
446
  applyUpdate=${onOpenclawUpdate}
445
447
  updateInProgress=${openclawUpdateInProgress}
448
+ updateLoadingLabel=${openclawRestarting ? "Restarting..." : "Updating..."}
446
449
  onActionComplete=${onOpenclawVersionActionComplete}
447
450
  />
448
451
  </div>
@@ -28,6 +28,7 @@ export const GeneralTab = ({
28
28
  restartingGateway = false,
29
29
  onRestartGateway = () => {},
30
30
  restartSignal = 0,
31
+ openclawRestarting = false,
31
32
  openclawUpdateInProgress = false,
32
33
  onOpenclawVersionActionComplete = () => {},
33
34
  onOpenclawUpdate = () => {},
@@ -54,6 +55,7 @@ export const GeneralTab = ({
54
55
  onOpenWatchdog=${() => onSwitchTab("watchdog")}
55
56
  onRepair=${actions.handleWatchdogRepair}
56
57
  repairing=${state.repairingWatchdog}
58
+ openclawRestarting=${openclawRestarting}
57
59
  openclawUpdateInProgress=${openclawUpdateInProgress}
58
60
  onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
59
61
  onOpenclawUpdate=${onOpenclawUpdate}
@@ -508,6 +508,44 @@ export const EyeLineIcon = ({ className = "" }) => html`
508
508
  </svg>
509
509
  `;
510
510
 
511
+ export const SunIcon = ({ className = "" }) => html`
512
+ <svg
513
+ class=${className}
514
+ viewBox="0 0 24 24"
515
+ fill="none"
516
+ stroke="currentColor"
517
+ stroke-width="2"
518
+ stroke-linecap="round"
519
+ stroke-linejoin="round"
520
+ aria-hidden="true"
521
+ >
522
+ <circle cx="12" cy="12" r="5" />
523
+ <line x1="12" y1="1" x2="12" y2="3" />
524
+ <line x1="12" y1="21" x2="12" y2="23" />
525
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
526
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
527
+ <line x1="1" y1="12" x2="3" y2="12" />
528
+ <line x1="21" y1="12" x2="23" y2="12" />
529
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
530
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
531
+ </svg>
532
+ `;
533
+
534
+ export const MoonIcon = ({ className = "" }) => html`
535
+ <svg
536
+ class=${className}
537
+ viewBox="0 0 24 24"
538
+ fill="none"
539
+ stroke="currentColor"
540
+ stroke-width="2"
541
+ stroke-linecap="round"
542
+ stroke-linejoin="round"
543
+ aria-hidden="true"
544
+ >
545
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
546
+ </svg>
547
+ `;
548
+
511
549
  export const FullscreenLineIcon = ({ className = "" }) => html`
512
550
  <svg
513
551
  class=${className}
@@ -5,6 +5,10 @@ import { Badge } from "../badge.js";
5
5
  import { SecretInput } from "../secret-input.js";
6
6
  import { ActionButton } from "../action-button.js";
7
7
  import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js";
8
+ import {
9
+ isCodexAuthCallbackMessage,
10
+ openCodexAuthWindow,
11
+ } from "../../lib/codex-oauth-window.js";
8
12
  import { showToast } from "../toast.js";
9
13
  import {
10
14
  kProviderAuthFields,
@@ -108,6 +112,7 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
108
112
  const [authWaiting, setAuthWaiting] = useState(false);
109
113
  const [manualInput, setManualInput] = useState("");
110
114
  const [exchanging, setExchanging] = useState(false);
115
+ const exchangeInFlightRef = useRef(false);
111
116
  const popupPollRef = useRef(null);
112
117
 
113
118
  useEffect(
@@ -117,6 +122,30 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
117
122
  [],
118
123
  );
119
124
 
125
+ const submitAuthInput = async (input) => {
126
+ const normalizedInput = String(input || "").trim();
127
+ if (!normalizedInput || exchangeInFlightRef.current) return;
128
+ exchangeInFlightRef.current = true;
129
+ setManualInput(normalizedInput);
130
+ setExchanging(true);
131
+ try {
132
+ const result = await exchangeCodexOAuth(normalizedInput);
133
+ if (!result.ok)
134
+ throw new Error(result.error || "Codex OAuth exchange failed");
135
+ setManualInput("");
136
+ showToast("Codex connected", "success");
137
+ setAuthStarted(false);
138
+ setAuthWaiting(false);
139
+ await onRefreshCodex();
140
+ } catch (err) {
141
+ setAuthWaiting(false);
142
+ showToast(err.message || "Codex OAuth exchange failed", "error");
143
+ } finally {
144
+ exchangeInFlightRef.current = false;
145
+ setExchanging(false);
146
+ }
147
+ };
148
+
120
149
  useEffect(() => {
121
150
  const onMessage = async (e) => {
122
151
  if (e.data?.codex === "success") {
@@ -124,6 +153,8 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
124
153
  setAuthStarted(false);
125
154
  setAuthWaiting(false);
126
155
  await onRefreshCodex();
156
+ } else if (isCodexAuthCallbackMessage(e.data)) {
157
+ await submitAuthInput(e.data.input);
127
158
  } else if (e.data?.codex === "error") {
128
159
  showToast(
129
160
  `Codex auth failed: ${e.data.message || "unknown error"}`,
@@ -133,19 +164,14 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
133
164
  };
134
165
  window.addEventListener("message", onMessage);
135
166
  return () => window.removeEventListener("message", onMessage);
136
- }, [onRefreshCodex]);
167
+ }, [onRefreshCodex, submitAuthInput]);
137
168
 
138
169
  const startAuth = () => {
139
170
  setAuthStarted(true);
140
171
  setAuthWaiting(true);
141
- const popup = window.open(
142
- "/auth/codex/start",
143
- "codex-auth",
144
- "popup=yes,width=640,height=780",
145
- );
172
+ const popup = openCodexAuthWindow();
146
173
  if (!popup || popup.closed) {
147
174
  setAuthWaiting(false);
148
- window.location.href = "/auth/codex/start";
149
175
  return;
150
176
  }
151
177
  if (popupPollRef.current) clearInterval(popupPollRef.current);
@@ -159,22 +185,7 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
159
185
  };
160
186
 
161
187
  const completeAuth = async () => {
162
- if (!manualInput.trim() || exchanging) return;
163
- setExchanging(true);
164
- try {
165
- const result = await exchangeCodexOAuth(manualInput.trim());
166
- if (!result.ok)
167
- throw new Error(result.error || "Codex OAuth exchange failed");
168
- setManualInput("");
169
- showToast("Codex connected", "success");
170
- setAuthStarted(false);
171
- setAuthWaiting(false);
172
- await onRefreshCodex();
173
- } catch (err) {
174
- showToast(err.message || "Codex OAuth exchange failed", "error");
175
- } finally {
176
- setExchanging(false);
177
- }
188
+ await submitAuthInput(manualInput);
178
189
  };
179
190
 
180
191
  const handleDisconnect = async () => {
@@ -198,7 +209,23 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
198
209
  ? html`<${Badge} tone="success">Connected</${Badge}>`
199
210
  : html`<${Badge} tone="warning">Not connected</${Badge}>`}
200
211
  </div>
201
- ${codexStatus.connected
212
+ ${authStarted
213
+ ? html`
214
+ <div class="flex items-center justify-between gap-2">
215
+ <p class="text-xs text-fg-muted">
216
+ ${authWaiting
217
+ ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't."
218
+ : "Paste the redirect URL from your browser to finish connecting."}
219
+ </p>
220
+ <button
221
+ onclick=${startAuth}
222
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
223
+ >
224
+ Restart
225
+ </button>
226
+ </div>
227
+ `
228
+ : codexStatus.connected
202
229
  ? html`
203
230
  <div class="flex gap-2">
204
231
  <button
@@ -215,32 +242,16 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
215
242
  </button>
216
243
  </div>
217
244
  `
218
- : !authStarted
245
+ : html`
246
+ <button
247
+ onclick=${startAuth}
248
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
249
+ >
250
+ Connect Codex OAuth
251
+ </button>
252
+ `}
253
+ ${authStarted
219
254
  ? html`
220
- <button
221
- onclick=${startAuth}
222
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
223
- >
224
- Connect Codex OAuth
225
- </button>
226
- `
227
- : html`
228
- <div class="flex items-center justify-between gap-2">
229
- <p class="text-xs text-fg-muted">
230
- ${authWaiting
231
- ? "Complete login in the popup, then paste the redirect URL."
232
- : "Paste the redirect URL from your browser to finish connecting."}
233
- </p>
234
- <button
235
- onclick=${startAuth}
236
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
237
- >
238
- Restart
239
- </button>
240
- </div>
241
- `}
242
- ${!codexStatus.connected && authStarted
243
- ? html`
244
255
  <p class="text-xs text-fg-muted">
245
256
  After login, copy the full redirect URL (starts with
246
257
  <code class="text-xs bg-field px-1 rounded"
@@ -8,16 +8,35 @@ import {
8
8
  } from "../../lib/api.js";
9
9
  import { showToast } from "../toast.js";
10
10
  import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
11
+ import { usePolling } from "../../hooks/usePolling.js";
12
+ import { invalidateCache } from "../../lib/api-cache.js";
13
+ import {
14
+ getModelCatalogModels,
15
+ isModelCatalogRefreshing,
16
+ kModelCatalogCacheKey,
17
+ kModelCatalogPollIntervalMs,
18
+ } from "../../lib/model-catalog.js";
11
19
 
12
20
  let kModelsTabCache = null;
13
21
  const getCredentialValue = (value) =>
14
22
  String(value?.key || value?.token || value?.access || "").trim();
23
+ const kNoModelsFoundError = "No models found";
24
+ const kModelSettingsLoadError = "Failed to load model settings";
15
25
 
16
26
  export const useModels = (agentId) => {
17
27
  const isScoped = !!agentId;
18
28
  const normalizedAgentId = String(agentId || "").trim();
19
29
  const useCache = !isScoped;
20
30
  const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []);
31
+ const [catalogStatus, setCatalogStatus] = useState(
32
+ () =>
33
+ (useCache && kModelsTabCache?.catalogStatus) || {
34
+ source: "",
35
+ fetchedAt: null,
36
+ stale: false,
37
+ refreshing: false,
38
+ },
39
+ );
21
40
  const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || "");
22
41
  const [configuredModels, setConfiguredModels] = useState(
23
42
  () => (useCache && kModelsTabCache?.configuredModels) || {},
@@ -48,7 +67,7 @@ export const useModels = (agentId) => {
48
67
  const modelsConfigCacheKey = normalizedAgentId
49
68
  ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}`
50
69
  : "/api/models/config";
51
- const catalogFetchState = useCachedFetch("/api/models", fetchModels, {
70
+ const catalogFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {
52
71
  maxAgeMs: 30000,
53
72
  });
54
73
  const configFetchState = useCachedFetch(
@@ -59,6 +78,41 @@ export const useModels = (agentId) => {
59
78
  const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, {
60
79
  maxAgeMs: 15000,
61
80
  });
81
+ const catalogPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, {
82
+ enabled: ready && isModelCatalogRefreshing(catalogStatus),
83
+ pauseWhenHidden: true,
84
+ cacheKey: kModelCatalogCacheKey,
85
+ });
86
+
87
+ const syncCatalogError = useCallback((catalogModels) => {
88
+ setError((current) => {
89
+ if (catalogModels.length > 0) {
90
+ return current === kNoModelsFoundError ? "" : current;
91
+ }
92
+ return current || kNoModelsFoundError;
93
+ });
94
+ }, []);
95
+
96
+ const applyCatalogResult = useCallback(
97
+ (catalogResult) => {
98
+ const catalogModels = getModelCatalogModels(catalogResult);
99
+ const nextCatalogStatus = {
100
+ source: String(catalogResult?.source || ""),
101
+ fetchedAt: Number(catalogResult?.fetchedAt || 0) || null,
102
+ stale: Boolean(catalogResult?.stale),
103
+ refreshing: Boolean(catalogResult?.refreshing),
104
+ };
105
+ setCatalog(catalogModels);
106
+ setCatalogStatus(nextCatalogStatus);
107
+ updateCache({
108
+ catalog: catalogModels,
109
+ catalogStatus: nextCatalogStatus,
110
+ });
111
+ syncCatalogError(catalogModels);
112
+ return catalogModels;
113
+ },
114
+ [syncCatalogError, updateCache],
115
+ );
62
116
 
63
117
  const refresh = useCallback(async () => {
64
118
  if (!ready) setLoading(true);
@@ -69,10 +123,7 @@ export const useModels = (agentId) => {
69
123
  configFetchState.refresh({ force: true }),
70
124
  codexFetchState.refresh({ force: true }),
71
125
  ]);
72
- const catalogModels = Array.isArray(catalogResult.models)
73
- ? catalogResult.models
74
- : [];
75
- setCatalog(catalogModels);
126
+ const catalogModels = applyCatalogResult(catalogResult);
76
127
  const p = configResult.primary || "";
77
128
  const cm = configResult.configuredModels || {};
78
129
  const ap = configResult.authProfiles || [];
@@ -94,20 +145,31 @@ export const useModels = (agentId) => {
94
145
  authOrder: ao,
95
146
  codexStatus: codex || { connected: false },
96
147
  });
97
- if (!catalogModels.length) setError("No models found");
98
148
  } catch (err) {
99
- setError("Failed to load model settings");
100
- showToast(`Failed to load model settings: ${err.message}`, "error");
149
+ setError(kModelSettingsLoadError);
150
+ showToast(`${kModelSettingsLoadError}: ${err.message}`, "error");
101
151
  } finally {
102
152
  setReady(true);
103
153
  setLoading(false);
104
154
  }
105
- }, [catalogFetchState, codexFetchState, configFetchState, ready, updateCache, agentId, isScoped]);
155
+ }, [
156
+ applyCatalogResult,
157
+ catalogFetchState,
158
+ codexFetchState,
159
+ configFetchState,
160
+ ready,
161
+ updateCache,
162
+ ]);
106
163
 
107
164
  useEffect(() => {
108
165
  refresh();
109
166
  }, [agentId]);
110
167
 
168
+ useEffect(() => {
169
+ if (!catalogPoll.data) return;
170
+ applyCatalogResult(catalogPoll.data);
171
+ }, [applyCatalogResult, catalogPoll.data]);
172
+
111
173
  const stableStringify = (obj) =>
112
174
  JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {}));
113
175
 
@@ -261,6 +323,7 @@ export const useModels = (agentId) => {
261
323
  if (result.syncWarning) {
262
324
  showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning");
263
325
  }
326
+ invalidateCache(kModelCatalogCacheKey);
264
327
  await refresh();
265
328
  } catch (err) {
266
329
  showToast(err.message || "Failed to save changes", "error");
@@ -274,6 +337,8 @@ export const useModels = (agentId) => {
274
337
  profileEdits,
275
338
  orderEdits,
276
339
  authProfiles,
340
+ isScoped,
341
+ agentId,
277
342
  refresh,
278
343
  ]);
279
344
 
@@ -24,6 +24,10 @@ import {
24
24
  kProviderLabels,
25
25
  kProviderOrder,
26
26
  } from "../lib/model-config.js";
27
+ import {
28
+ isCodexAuthCallbackMessage,
29
+ openCodexAuthWindow,
30
+ } from "../lib/codex-oauth-window.js";
27
31
 
28
32
  const html = htm.bind(h);
29
33
 
@@ -51,6 +55,7 @@ export const Models = () => {
51
55
  const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || "");
52
56
  const [modelDirty, setModelDirty] = useState(false);
53
57
  const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {});
58
+ const codexExchangeInFlightRef = useRef(false);
54
59
  const codexPopupPollRef = useRef(null);
55
60
 
56
61
  const refresh = async () => {
@@ -122,18 +127,43 @@ export const Models = () => {
122
127
  }
123
128
  }, []);
124
129
 
130
+ const submitCodexAuthInput = async (input) => {
131
+ const normalizedInput = String(input || "").trim();
132
+ if (!normalizedInput || codexExchangeInFlightRef.current) return;
133
+ codexExchangeInFlightRef.current = true;
134
+ setCodexManualInput(normalizedInput);
135
+ setCodexExchanging(true);
136
+ try {
137
+ const result = await exchangeCodexOAuth(normalizedInput);
138
+ if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed");
139
+ setCodexManualInput("");
140
+ showToast("Codex connected", "success");
141
+ setCodexAuthStarted(false);
142
+ setCodexAuthWaiting(false);
143
+ await refreshCodexConnection();
144
+ } catch (err) {
145
+ setCodexAuthWaiting(false);
146
+ showToast(err.message || "Codex OAuth exchange failed", "error");
147
+ } finally {
148
+ codexExchangeInFlightRef.current = false;
149
+ setCodexExchanging(false);
150
+ }
151
+ };
152
+
125
153
  useEffect(() => {
126
154
  const onMessage = async (e) => {
127
155
  if (e.data?.codex === "success") {
128
156
  showToast("Codex connected", "success");
129
157
  await refreshCodexConnection();
158
+ } else if (isCodexAuthCallbackMessage(e.data)) {
159
+ await submitCodexAuthInput(e.data.input);
130
160
  } else if (e.data?.codex === "error") {
131
161
  showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "error");
132
162
  }
133
163
  };
134
164
  window.addEventListener("message", onMessage);
135
165
  return () => window.removeEventListener("message", onMessage);
136
- }, []);
166
+ }, [submitCodexAuthInput]);
137
167
 
138
168
  const setEnvValue = (key, value) => {
139
169
  setEnvVars((prev) => {
@@ -194,10 +224,9 @@ export const Models = () => {
194
224
  if (codexStatus.connected) return;
195
225
  setCodexAuthStarted(true);
196
226
  setCodexAuthWaiting(true);
197
- const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780");
227
+ const popup = openCodexAuthWindow();
198
228
  if (!popup || popup.closed) {
199
229
  setCodexAuthWaiting(false);
200
- window.location.href = "/auth/codex/start";
201
230
  return;
202
231
  }
203
232
  if (codexPopupPollRef.current) {
@@ -213,21 +242,7 @@ export const Models = () => {
213
242
  };
214
243
 
215
244
  const completeCodexAuth = async () => {
216
- if (!codexManualInput.trim() || codexExchanging) return;
217
- setCodexExchanging(true);
218
- try {
219
- const result = await exchangeCodexOAuth(codexManualInput.trim());
220
- if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed");
221
- setCodexManualInput("");
222
- showToast("Codex connected", "success");
223
- setCodexAuthStarted(false);
224
- setCodexAuthWaiting(false);
225
- await refreshCodexConnection();
226
- } catch (err) {
227
- showToast(err.message || "Codex OAuth exchange failed", "error");
228
- } finally {
229
- setCodexExchanging(false);
230
- }
245
+ await submitCodexAuthInput(codexManualInput);
231
246
  };
232
247
 
233
248
  const handleCodexDisconnect = async () => {
@@ -301,7 +316,23 @@ export const Models = () => {
301
316
  ? html`<${Badge} tone="success">Connected</${Badge}>`
302
317
  : html`<${Badge} tone="warning">Not connected</${Badge}>`}
303
318
  </div>
304
- ${codexStatus.connected
319
+ ${codexAuthStarted
320
+ ? html`
321
+ <div class="flex items-center justify-between gap-2">
322
+ <p class="text-xs text-fg-muted">
323
+ ${codexAuthWaiting
324
+ ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't."
325
+ : "Paste the redirect URL from your browser to finish connecting."}
326
+ </p>
327
+ <button
328
+ onclick=${startCodexAuth}
329
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
330
+ >
331
+ Restart
332
+ </button>
333
+ </div>
334
+ `
335
+ : codexStatus.connected
305
336
  ? html`
306
337
  <div class="flex gap-2">
307
338
  <button
@@ -318,31 +349,15 @@ export const Models = () => {
318
349
  </button>
319
350
  </div>
320
351
  `
321
- : !codexAuthStarted
322
- ? html`
352
+ : html`
323
353
  <button
324
354
  onclick=${startCodexAuth}
325
355
  class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
326
356
  >
327
357
  Connect Codex OAuth
328
358
  </button>
329
- `
330
- : html`
331
- <div class="flex items-center justify-between gap-2">
332
- <p class="text-xs text-fg-muted">
333
- ${codexAuthWaiting
334
- ? "Complete login in the popup, then paste the redirect URL."
335
- : "Paste the redirect URL from your browser to finish connecting."}
336
- </p>
337
- <button
338
- onclick=${startCodexAuth}
339
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
340
- >
341
- Restart
342
- </button>
343
- </div>
344
359
  `}
345
- ${!codexStatus.connected && codexAuthStarted
360
+ ${codexAuthStarted
346
361
  ? html`
347
362
  <p class="text-xs text-fg-muted">
348
363
  After login, copy the full redirect URL (starts with