@chrysb/alphaclaw 0.4.6-beta.3 → 0.4.6-beta.5

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 (39) hide show
  1. package/lib/public/js/app.js +158 -1073
  2. package/lib/public/js/components/doctor/index.js +1 -2
  3. package/lib/public/js/components/general/index.js +155 -0
  4. package/lib/public/js/components/general/use-general-tab.js +233 -0
  5. package/lib/public/js/components/models-tab/index.js +286 -0
  6. package/lib/public/js/components/models-tab/provider-auth-card.js +369 -0
  7. package/lib/public/js/components/models-tab/use-models.js +262 -0
  8. package/lib/public/js/components/routes/browse-route.js +35 -0
  9. package/lib/public/js/components/routes/doctor-route.js +21 -0
  10. package/lib/public/js/components/routes/envars-route.js +11 -0
  11. package/lib/public/js/components/routes/general-route.js +45 -0
  12. package/lib/public/js/components/routes/index.js +11 -0
  13. package/lib/public/js/components/routes/models-route.js +11 -0
  14. package/lib/public/js/components/routes/providers-route.js +11 -0
  15. package/lib/public/js/components/routes/route-redirect.js +10 -0
  16. package/lib/public/js/components/routes/telegram-route.js +11 -0
  17. package/lib/public/js/components/routes/usage-route.js +15 -0
  18. package/lib/public/js/components/routes/watchdog-route.js +32 -0
  19. package/lib/public/js/components/routes/webhooks-route.js +43 -0
  20. package/lib/public/js/components/sidebar.js +2 -3
  21. package/lib/public/js/components/usage-tab/constants.js +1 -1
  22. package/lib/public/js/components/usage-tab/overview-section.js +124 -50
  23. package/lib/public/js/components/usage-tab/use-usage-tab.js +42 -11
  24. package/lib/public/js/hooks/use-app-shell-controller.js +230 -0
  25. package/lib/public/js/hooks/use-app-shell-ui.js +112 -0
  26. package/lib/public/js/hooks/use-browse-navigation.js +193 -0
  27. package/lib/public/js/hooks/use-hash-location.js +32 -0
  28. package/lib/public/js/lib/api.js +35 -0
  29. package/lib/public/js/lib/app-navigation.js +39 -0
  30. package/lib/public/js/lib/browse-restart-policy.js +28 -0
  31. package/lib/public/js/lib/browse-route.js +57 -0
  32. package/lib/public/js/lib/format.js +12 -0
  33. package/lib/server/auth-profiles.js +223 -52
  34. package/lib/server/doctor/prompt.js +4 -1
  35. package/lib/server/gateway.js +29 -9
  36. package/lib/server/routes/models.js +170 -2
  37. package/lib/server/watchdog.js +14 -1
  38. package/lib/server.js +1 -0
  39. package/package.json +1 -1
@@ -462,9 +462,8 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
462
462
  ? html`
463
463
  <div class="ac-surface-inset rounded-xl p-4">
464
464
  <div
465
- class="flex items-center gap-2 text-xs leading-5 text-gray-300"
465
+ class="text-xs leading-5 text-gray-400"
466
466
  >
467
- <${LoadingSpinner} className="h-3.5 w-3.5" />
468
467
  <span
469
468
  >Run in progress. Findings will appear when analysis
470
469
  completes.</span
@@ -0,0 +1,155 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Gateway } from "../gateway.js";
4
+ import { Channels } from "../channels.js";
5
+ import { Pairings } from "../pairings.js";
6
+ import { DevicePairings } from "../device-pairings.js";
7
+ import { Google } from "../google/index.js";
8
+ import { Features } from "../features.js";
9
+ import { GeneralDoctorWarning } from "../doctor/general-warning.js";
10
+ import { ChevronDownIcon } from "../icons.js";
11
+ import { UpdateActionButton } from "../update-action-button.js";
12
+ import { useGeneralTab } from "./use-general-tab.js";
13
+
14
+ const html = htm.bind(h);
15
+
16
+ export const GeneralTab = ({
17
+ statusData = null,
18
+ watchdogData = null,
19
+ doctorStatusData = null,
20
+ doctorWarningDismissedUntilMs = 0,
21
+ onRefreshStatuses = () => {},
22
+ onSwitchTab = () => {},
23
+ onNavigate = () => {},
24
+ onOpenGmailWebhook = () => {},
25
+ isActive = false,
26
+ restartingGateway = false,
27
+ onRestartGateway = () => {},
28
+ restartSignal = 0,
29
+ openclawUpdateInProgress = false,
30
+ onOpenclawVersionActionComplete = () => {},
31
+ onOpenclawUpdate = () => {},
32
+ onRestartRequired = () => {},
33
+ onDismissDoctorWarning = () => {},
34
+ }) => {
35
+ const { state, actions } = useGeneralTab({
36
+ statusData,
37
+ watchdogData,
38
+ doctorStatusData,
39
+ onRefreshStatuses,
40
+ isActive,
41
+ restartSignal,
42
+ });
43
+
44
+ return html`
45
+ <div class="space-y-4">
46
+ <${Gateway}
47
+ status=${state.gatewayStatus}
48
+ openclawVersion=${state.openclawVersion}
49
+ restarting=${restartingGateway}
50
+ onRestart=${onRestartGateway}
51
+ watchdogStatus=${state.watchdogStatus}
52
+ onOpenWatchdog=${() => onSwitchTab("watchdog")}
53
+ onRepair=${actions.handleWatchdogRepair}
54
+ repairing=${state.repairingWatchdog}
55
+ openclawUpdateInProgress=${openclawUpdateInProgress}
56
+ onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
57
+ onOpenclawUpdate=${onOpenclawUpdate}
58
+ />
59
+ <${GeneralDoctorWarning}
60
+ doctorStatus=${state.doctorStatus}
61
+ dismissedUntilMs=${doctorWarningDismissedUntilMs}
62
+ onOpenDoctor=${() => onSwitchTab("doctor")}
63
+ onDismiss=${onDismissDoctorWarning}
64
+ />
65
+ <${Channels}
66
+ channels=${state.channels}
67
+ onSwitchTab=${onSwitchTab}
68
+ onNavigate=${onNavigate}
69
+ />
70
+ <${Pairings}
71
+ pending=${state.pending}
72
+ channels=${state.channels}
73
+ visible=${state.hasUnpaired}
74
+ onApprove=${actions.handleApprove}
75
+ onReject=${actions.handleReject}
76
+ />
77
+ <${Features} onSwitchTab=${onSwitchTab} />
78
+ <${Google}
79
+ gatewayStatus=${state.gatewayStatus}
80
+ onRestartRequired=${onRestartRequired}
81
+ onOpenGmailWebhook=${onOpenGmailWebhook}
82
+ />
83
+
84
+ ${state.repo &&
85
+ html`
86
+ <div class="bg-surface border border-border rounded-xl p-4">
87
+ <div class="flex items-center justify-between gap-3">
88
+ <div class="flex items-center gap-2 min-w-0">
89
+ <svg
90
+ class="w-4 h-4 text-gray-400"
91
+ viewBox="0 0 16 16"
92
+ fill="currentColor"
93
+ >
94
+ <path
95
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
96
+ />
97
+ </svg>
98
+ <a
99
+ href="https://github.com/${state.repo}"
100
+ target="_blank"
101
+ class="text-sm text-gray-400 hover:text-gray-200 transition-colors truncate"
102
+ >${state.repo}</a
103
+ >
104
+ </div>
105
+ <div class="flex items-center gap-2 shrink-0">
106
+ <span class="text-xs text-gray-400">Auto-sync</span>
107
+ <div class="relative">
108
+ <select
109
+ value=${state.syncCronChoice}
110
+ onchange=${(event) =>
111
+ actions.handleSyncCronChoiceChange(event.target.value)}
112
+ disabled=${state.savingSyncCron}
113
+ class="appearance-none bg-black/30 border border-border rounded-lg pl-2.5 pr-9 py-1.5 text-xs text-gray-300 ${state.savingSyncCron
114
+ ? "opacity-50 cursor-not-allowed"
115
+ : ""}"
116
+ title=${state.syncCron?.installed === false
117
+ ? "Not Installed Yet"
118
+ : state.syncCronStatusText}
119
+ >
120
+ <option value="disabled">Disabled</option>
121
+ <option value="*/30 * * * *">Every 30 min</option>
122
+ <option value="0 * * * *">Hourly</option>
123
+ <option value="0 0 * * *">Daily</option>
124
+ </select>
125
+ <${ChevronDownIcon}
126
+ className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500"
127
+ />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ `}
133
+
134
+ <div class="bg-surface border border-border rounded-xl p-4">
135
+ <div class="flex items-center justify-between">
136
+ <div>
137
+ <h2 class="font-semibold text-sm">OpenClaw Gateway Dashboard</h2>
138
+ </div>
139
+ <${UpdateActionButton}
140
+ onClick=${actions.handleOpenDashboard}
141
+ loading=${state.dashboardLoading}
142
+ warning=${false}
143
+ idleLabel="Open"
144
+ loadingLabel="Opening..."
145
+ />
146
+ </div>
147
+ <${DevicePairings}
148
+ pending=${state.devicePending}
149
+ onApprove=${actions.handleDeviceApprove}
150
+ onReject=${actions.handleDeviceReject}
151
+ />
152
+ </div>
153
+ </div>
154
+ `;
155
+ };
@@ -0,0 +1,233 @@
1
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ approveDevice,
4
+ approvePairing,
5
+ fetchDashboardUrl,
6
+ fetchDevicePairings,
7
+ fetchPairings,
8
+ rejectDevice,
9
+ rejectPairing,
10
+ triggerWatchdogRepair,
11
+ updateSyncCron,
12
+ } from "../../lib/api.js";
13
+ import { usePolling } from "../../hooks/usePolling.js";
14
+ import { showToast } from "../toast.js";
15
+ import { ALL_CHANNELS } from "../channels.js";
16
+
17
+ const kDefaultSyncCronSchedule = "0 * * * *";
18
+
19
+ export const useGeneralTab = ({
20
+ statusData = null,
21
+ watchdogData = null,
22
+ doctorStatusData = null,
23
+ onRefreshStatuses = () => {},
24
+ isActive = false,
25
+ restartSignal = 0,
26
+ } = {}) => {
27
+ const [dashboardLoading, setDashboardLoading] = useState(false);
28
+ const [repairingWatchdog, setRepairingWatchdog] = useState(false);
29
+ const [syncCronEnabled, setSyncCronEnabled] = useState(true);
30
+ const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule);
31
+ const [savingSyncCron, setSavingSyncCron] = useState(false);
32
+ const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule);
33
+
34
+ const status = statusData;
35
+ const watchdogStatus = watchdogData;
36
+ const doctorStatus = doctorStatusData;
37
+ const gatewayStatus = status?.gateway ?? null;
38
+ const channels = status?.channels ?? null;
39
+ const repo = status?.repo || null;
40
+ const syncCron = status?.syncCron || null;
41
+ const openclawVersion = status?.openclawVersion || null;
42
+
43
+ const hasUnpaired = ALL_CHANNELS.some((channel) => {
44
+ const info = channels?.[channel];
45
+ return info && info.status !== "paired";
46
+ });
47
+
48
+ const pairingsPoll = usePolling(
49
+ async () => {
50
+ const data = await fetchPairings();
51
+ return data.pending || [];
52
+ },
53
+ 1000,
54
+ { enabled: hasUnpaired && gatewayStatus === "running" },
55
+ );
56
+ const pending = pairingsPoll.data || [];
57
+
58
+ const devicePoll = usePolling(
59
+ async () => {
60
+ const data = await fetchDevicePairings();
61
+ return data.pending || [];
62
+ },
63
+ 2000,
64
+ { enabled: gatewayStatus === "running" },
65
+ );
66
+ const devicePending = devicePoll.data || [];
67
+
68
+ useEffect(() => {
69
+ if (!isActive) return;
70
+ onRefreshStatuses();
71
+ pairingsPoll.refresh();
72
+ devicePoll.refresh();
73
+ }, [devicePoll.refresh, isActive, onRefreshStatuses, pairingsPoll.refresh]);
74
+
75
+ useEffect(() => {
76
+ if (!restartSignal || !isActive) return;
77
+ onRefreshStatuses();
78
+ pairingsPoll.refresh();
79
+ devicePoll.refresh();
80
+ const t1 = setTimeout(() => {
81
+ onRefreshStatuses();
82
+ pairingsPoll.refresh();
83
+ devicePoll.refresh();
84
+ }, 1200);
85
+ const t2 = setTimeout(() => {
86
+ onRefreshStatuses();
87
+ pairingsPoll.refresh();
88
+ devicePoll.refresh();
89
+ }, 3500);
90
+ return () => {
91
+ clearTimeout(t1);
92
+ clearTimeout(t2);
93
+ };
94
+ }, [
95
+ devicePoll.refresh,
96
+ isActive,
97
+ onRefreshStatuses,
98
+ pairingsPoll.refresh,
99
+ restartSignal,
100
+ ]);
101
+
102
+ useEffect(() => {
103
+ if (!syncCron) return;
104
+ setSyncCronEnabled(syncCron.enabled !== false);
105
+ setSyncCronSchedule(syncCron.schedule || kDefaultSyncCronSchedule);
106
+ setSyncCronChoice(
107
+ syncCron.enabled === false ? "disabled" : syncCron.schedule || kDefaultSyncCronSchedule,
108
+ );
109
+ }, [syncCron?.enabled, syncCron?.schedule]);
110
+
111
+ const refreshAfterPairingAction = () => {
112
+ setTimeout(pairingsPoll.refresh, 500);
113
+ setTimeout(pairingsPoll.refresh, 2000);
114
+ setTimeout(onRefreshStatuses, 3000);
115
+ };
116
+
117
+ const saveSyncCronSettings = async ({
118
+ enabled = syncCronEnabled,
119
+ schedule = syncCronSchedule,
120
+ } = {}) => {
121
+ if (savingSyncCron) return;
122
+ setSavingSyncCron(true);
123
+ try {
124
+ const data = await updateSyncCron({ enabled, schedule });
125
+ if (!data.ok) {
126
+ throw new Error(data.error || "Could not save sync settings");
127
+ }
128
+ showToast("Sync schedule updated", "success");
129
+ onRefreshStatuses();
130
+ } catch (err) {
131
+ showToast(err.message || "Could not save sync settings", "error");
132
+ } finally {
133
+ setSavingSyncCron(false);
134
+ }
135
+ };
136
+
137
+ const handleSyncCronChoiceChange = async (nextChoice) => {
138
+ setSyncCronChoice(nextChoice);
139
+ const nextEnabled = nextChoice !== "disabled";
140
+ const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule;
141
+ setSyncCronEnabled(nextEnabled);
142
+ setSyncCronSchedule(nextSchedule);
143
+ await saveSyncCronSettings({
144
+ enabled: nextEnabled,
145
+ schedule: nextSchedule,
146
+ });
147
+ };
148
+
149
+ const handleApprove = async (id, channel) => {
150
+ await approvePairing(id, channel);
151
+ refreshAfterPairingAction();
152
+ };
153
+
154
+ const handleReject = async (id, channel) => {
155
+ await rejectPairing(id, channel);
156
+ refreshAfterPairingAction();
157
+ };
158
+
159
+ const handleDeviceApprove = async (id) => {
160
+ await approveDevice(id);
161
+ setTimeout(devicePoll.refresh, 500);
162
+ setTimeout(devicePoll.refresh, 2000);
163
+ };
164
+
165
+ const handleDeviceReject = async (id) => {
166
+ await rejectDevice(id);
167
+ setTimeout(devicePoll.refresh, 500);
168
+ setTimeout(devicePoll.refresh, 2000);
169
+ };
170
+
171
+ const handleWatchdogRepair = async () => {
172
+ if (repairingWatchdog) return;
173
+ setRepairingWatchdog(true);
174
+ try {
175
+ const data = await triggerWatchdogRepair();
176
+ if (!data.ok) throw new Error(data.error || "Repair failed");
177
+ showToast("Repair triggered", "success");
178
+ setTimeout(() => {
179
+ onRefreshStatuses();
180
+ }, 800);
181
+ } catch (err) {
182
+ showToast(err.message || "Could not run repair", "error");
183
+ } finally {
184
+ setRepairingWatchdog(false);
185
+ }
186
+ };
187
+
188
+ const handleOpenDashboard = async () => {
189
+ if (dashboardLoading) return;
190
+ setDashboardLoading(true);
191
+ try {
192
+ const data = await fetchDashboardUrl();
193
+ console.log("[dashboard] response:", JSON.stringify(data));
194
+ window.open(data.url || "/openclaw", "_blank");
195
+ } catch (err) {
196
+ console.error("[dashboard] error:", err);
197
+ window.open("/openclaw", "_blank");
198
+ } finally {
199
+ setDashboardLoading(false);
200
+ }
201
+ };
202
+
203
+ return {
204
+ state: {
205
+ channels,
206
+ dashboardLoading,
207
+ devicePending,
208
+ doctorStatus,
209
+ gatewayStatus,
210
+ hasUnpaired,
211
+ openclawVersion,
212
+ pending,
213
+ repairingWatchdog,
214
+ repo,
215
+ savingSyncCron,
216
+ syncCron,
217
+ syncCronChoice,
218
+ syncCronEnabled,
219
+ syncCronSchedule,
220
+ syncCronStatusText: syncCronEnabled ? "Enabled" : "Disabled",
221
+ watchdogStatus,
222
+ },
223
+ actions: {
224
+ handleApprove,
225
+ handleDeviceApprove,
226
+ handleDeviceReject,
227
+ handleOpenDashboard,
228
+ handleReject,
229
+ handleSyncCronChoiceChange,
230
+ handleWatchdogRepair,
231
+ },
232
+ };
233
+ };
@@ -0,0 +1,286 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useMemo } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { PageHeader } from "../page-header.js";
5
+ import { LoadingSpinner } from "../loading-spinner.js";
6
+ import { ActionButton } from "../action-button.js";
7
+ import { Badge } from "../badge.js";
8
+ import { useModels } from "./use-models.js";
9
+ import { ProviderAuthCard } from "./provider-auth-card.js";
10
+ import { getModelProvider, getFeaturedModels } from "../../lib/model-config.js";
11
+
12
+ const html = htm.bind(h);
13
+
14
+ const deriveRequiredProviders = (configuredModels) => {
15
+ const providers = new Set();
16
+ for (const modelKey of Object.keys(configuredModels)) {
17
+ const provider = getModelProvider(modelKey);
18
+ if (provider) providers.add(provider);
19
+ }
20
+ return [...providers];
21
+ };
22
+
23
+ const kProviderDisplayOrder = [
24
+ "anthropic",
25
+ "openai",
26
+ "openai-codex",
27
+ "google",
28
+ ];
29
+
30
+ export const Models = ({ onRestartRequired = () => {} }) => {
31
+ const {
32
+ catalog,
33
+ primary,
34
+ configuredModels,
35
+ authProfiles,
36
+ authOrder,
37
+ codexStatus,
38
+ loading,
39
+ saving,
40
+ ready,
41
+ error,
42
+ isDirty,
43
+ addModel,
44
+ removeModel,
45
+ setPrimaryModel,
46
+ editProfile,
47
+ editAuthOrder,
48
+ getProfileValue,
49
+ getEffectiveOrder,
50
+ cancelChanges,
51
+ saveAll,
52
+ refreshCodexStatus,
53
+ } = useModels();
54
+
55
+ const [showAllModels, setShowAllModels] = useState(false);
56
+
57
+ const configuredKeys = useMemo(
58
+ () => new Set(Object.keys(configuredModels)),
59
+ [configuredModels],
60
+ );
61
+
62
+ const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]);
63
+
64
+ const pickerModels = useMemo(() => {
65
+ const base = showAllModels
66
+ ? catalog
67
+ : featuredModels.length > 0
68
+ ? featuredModels
69
+ : catalog;
70
+ return base.filter((m) => !configuredKeys.has(m.key));
71
+ }, [catalog, featuredModels, showAllModels, configuredKeys]);
72
+
73
+ const canToggleFullCatalog =
74
+ featuredModels.length > 0 && catalog.length > featuredModels.length;
75
+
76
+ const requiredProviders = useMemo(
77
+ () => deriveRequiredProviders(configuredModels),
78
+ [configuredModels],
79
+ );
80
+
81
+ const sortedProviders = useMemo(() => {
82
+ const ordered = [];
83
+ for (const p of kProviderDisplayOrder) {
84
+ if (requiredProviders.includes(p)) ordered.push(p);
85
+ }
86
+ for (const p of requiredProviders) {
87
+ if (!ordered.includes(p)) ordered.push(p);
88
+ }
89
+ return ordered;
90
+ }, [requiredProviders]);
91
+
92
+ const providerHasAuth = useMemo(() => {
93
+ const result = {};
94
+ for (const p of authProfiles) {
95
+ if (p.key || p.token || p.access) {
96
+ result[p.provider] = true;
97
+ }
98
+ }
99
+ if (codexStatus?.connected) {
100
+ result["openai-codex"] = true;
101
+ }
102
+ return result;
103
+ }, [authProfiles, codexStatus]);
104
+
105
+ const configuredModelEntries = useMemo(
106
+ () =>
107
+ Object.keys(configuredModels).map((key) => {
108
+ const catalogEntry = catalog.find((m) => m.key === key);
109
+ const provider = getModelProvider(key);
110
+ const hasAuth = !!providerHasAuth[provider];
111
+ return {
112
+ key,
113
+ label: catalogEntry?.label || key,
114
+ isPrimary: key === primary,
115
+ hasAuth,
116
+ };
117
+ }),
118
+ [configuredModels, catalog, primary, providerHasAuth],
119
+ );
120
+
121
+ if (!ready) {
122
+ return html`
123
+ <div class="space-y-4">
124
+ <${PageHeader}
125
+ title="Models"
126
+ actions=${html`
127
+ <${ActionButton}
128
+ disabled=${true}
129
+ tone="primary"
130
+ size="sm"
131
+ idleLabel="Save changes"
132
+ className="transition-all"
133
+ />
134
+ `}
135
+ />
136
+ <div class="bg-surface border border-border rounded-xl p-4">
137
+ <div class="flex items-center gap-2 text-sm text-gray-400">
138
+ <${LoadingSpinner} className="h-4 w-4" />
139
+ Loading model settings...
140
+ </div>
141
+ </div>
142
+ </div>
143
+ `;
144
+ }
145
+
146
+ return html`
147
+ <div class="space-y-4">
148
+ <${PageHeader}
149
+ title="Models"
150
+ actions=${html`
151
+ <${ActionButton}
152
+ onClick=${cancelChanges}
153
+ disabled=${!isDirty || saving}
154
+ tone="secondary"
155
+ size="sm"
156
+ idleLabel="Cancel"
157
+ className="transition-all"
158
+ />
159
+ <${ActionButton}
160
+ onClick=${saveAll}
161
+ disabled=${!isDirty || saving}
162
+ loading=${saving}
163
+ tone="primary"
164
+ size="sm"
165
+ idleLabel="Save changes"
166
+ loadingLabel="Saving..."
167
+ className="transition-all"
168
+ />
169
+ `}
170
+ />
171
+
172
+ <!-- Configured Models -->
173
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
174
+ <h2 class="font-semibold text-sm">Available Models</h2>
175
+
176
+ ${configuredModelEntries.length === 0
177
+ ? html`<p class="text-xs text-gray-500">
178
+ No models configured. Add a model below.
179
+ </p>`
180
+ : html`
181
+ <div class="space-y-1">
182
+ ${configuredModelEntries.map(
183
+ (entry) => html`
184
+ <div
185
+ class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-white/5"
186
+ >
187
+ <div class="flex items-center gap-2 min-w-0">
188
+ <span class="text-sm text-gray-200 truncate"
189
+ >${entry.label}</span
190
+ >
191
+ ${entry.isPrimary
192
+ ? html`<${Badge} tone="cyan">Primary</${Badge}>`
193
+ : entry.hasAuth
194
+ ? html`
195
+ <button
196
+ onclick=${() => setPrimaryModel(entry.key)}
197
+ class="text-xs px-2 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
198
+ >
199
+ Set primary
200
+ </button>
201
+ `
202
+ : html`<${Badge} tone="warning">Needs auth</${Badge}>`}
203
+ </div>
204
+ <button
205
+ onclick=${() => removeModel(entry.key)}
206
+ class="text-xs text-gray-600 hover:text-red-400 shrink-0 px-1"
207
+ >
208
+ Remove
209
+ </button>
210
+ </div>
211
+ `,
212
+ )}
213
+ </div>
214
+ `}
215
+
216
+ <div class="pt-2 border-t border-border space-y-2">
217
+ <label class="text-xs font-medium text-gray-400">Add model</label>
218
+ <select
219
+ onInput=${(e) => {
220
+ const val = e.target.value;
221
+ if (val) {
222
+ addModel(val);
223
+ if (!primary) setPrimaryModel(val);
224
+ }
225
+ e.target.value = "";
226
+ }}
227
+ class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
228
+ >
229
+ <option value="">Select a model to add...</option>
230
+ ${pickerModels.map(
231
+ (m) =>
232
+ html`<option value=${m.key}>${m.label || m.key}</option>`,
233
+ )}
234
+ </select>
235
+ ${canToggleFullCatalog
236
+ ? html`
237
+ <button
238
+ type="button"
239
+ onclick=${() => setShowAllModels((prev) => !prev)}
240
+ class="text-xs text-gray-500 hover:text-gray-300"
241
+ >
242
+ ${showAllModels
243
+ ? "Show recommended models"
244
+ : "Show full model catalog"}
245
+ </button>
246
+ `
247
+ : null}
248
+ </div>
249
+
250
+ ${loading
251
+ ? html`<p class="text-xs text-gray-600">
252
+ Loading model catalog...
253
+ </p>`
254
+ : error
255
+ ? html`<p class="text-xs text-gray-600">${error}</p>`
256
+ : null}
257
+ </div>
258
+
259
+ <!-- Provider Auth -->
260
+ ${sortedProviders.length > 0
261
+ ? html`
262
+ <div class="space-y-3">
263
+ <h2 class="font-semibold text-sm text-gray-300">
264
+ Provider Authentication
265
+ </h2>
266
+ ${sortedProviders.map(
267
+ (provider) => html`
268
+ <${ProviderAuthCard}
269
+ provider=${provider}
270
+ authProfiles=${authProfiles}
271
+ authOrder=${authOrder}
272
+ codexStatus=${codexStatus}
273
+ onEditProfile=${editProfile}
274
+ onEditAuthOrder=${editAuthOrder}
275
+ getProfileValue=${getProfileValue}
276
+ getEffectiveOrder=${getEffectiveOrder}
277
+ onRefreshCodex=${refreshCodexStatus}
278
+ />
279
+ `,
280
+ )}
281
+ </div>
282
+ `
283
+ : null}
284
+ </div>
285
+ `;
286
+ };