@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
@@ -0,0 +1,369 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useRef, useEffect } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { Badge } from "../badge.js";
5
+ import { SecretInput } from "../secret-input.js";
6
+ import { ActionButton } from "../action-button.js";
7
+ import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js";
8
+ import { showToast } from "../toast.js";
9
+
10
+ const html = htm.bind(h);
11
+
12
+ const kProviderMeta = {
13
+ anthropic: {
14
+ label: "Anthropic",
15
+ modes: [
16
+ {
17
+ id: "api_key",
18
+ label: "API Key",
19
+ profileSuffix: "default",
20
+ placeholder: "sk-ant-api03-...",
21
+ url: "https://console.anthropic.com",
22
+ field: "key",
23
+ },
24
+ {
25
+ id: "token",
26
+ label: "Setup Token",
27
+ profileSuffix: "manual",
28
+ placeholder: "sk-ant-oat01-...",
29
+ hint: "From claude setup-token (uses your Claude subscription)",
30
+ field: "token",
31
+ },
32
+ ],
33
+ },
34
+ openai: {
35
+ label: "OpenAI",
36
+ modes: [
37
+ {
38
+ id: "api_key",
39
+ label: "API Key",
40
+ profileSuffix: "default",
41
+ placeholder: "sk-...",
42
+ url: "https://platform.openai.com",
43
+ field: "key",
44
+ },
45
+ ],
46
+ },
47
+ "openai-codex": {
48
+ label: "OpenAI Codex",
49
+ modes: [{ id: "oauth", label: "Codex OAuth", isCodexOauth: true }],
50
+ },
51
+ google: {
52
+ label: "Gemini",
53
+ modes: [
54
+ {
55
+ id: "api_key",
56
+ label: "API Key",
57
+ profileSuffix: "default",
58
+ placeholder: "AI...",
59
+ url: "https://aistudio.google.com",
60
+ field: "key",
61
+ },
62
+ ],
63
+ },
64
+ };
65
+
66
+ const kDefaultMode = {
67
+ id: "api_key",
68
+ label: "API Key",
69
+ profileSuffix: "default",
70
+ placeholder: "...",
71
+ field: "key",
72
+ };
73
+
74
+ const getProviderMeta = (provider) =>
75
+ kProviderMeta[provider] || {
76
+ label: provider,
77
+ modes: [kDefaultMode],
78
+ };
79
+
80
+ const resolveProfileId = (mode, provider) => {
81
+ const p = mode.provider || provider;
82
+ return `${p}:${mode.profileSuffix || "default"}`;
83
+ };
84
+
85
+ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
86
+ const [authStarted, setAuthStarted] = useState(false);
87
+ const [authWaiting, setAuthWaiting] = useState(false);
88
+ const [manualInput, setManualInput] = useState("");
89
+ const [exchanging, setExchanging] = useState(false);
90
+ const popupPollRef = useRef(null);
91
+
92
+ useEffect(
93
+ () => () => {
94
+ if (popupPollRef.current) clearInterval(popupPollRef.current);
95
+ },
96
+ [],
97
+ );
98
+
99
+ useEffect(() => {
100
+ const onMessage = async (e) => {
101
+ if (e.data?.codex === "success") {
102
+ showToast("Codex connected", "success");
103
+ setAuthStarted(false);
104
+ setAuthWaiting(false);
105
+ await onRefreshCodex();
106
+ } else if (e.data?.codex === "error") {
107
+ showToast(
108
+ `Codex auth failed: ${e.data.message || "unknown error"}`,
109
+ "error",
110
+ );
111
+ }
112
+ };
113
+ window.addEventListener("message", onMessage);
114
+ return () => window.removeEventListener("message", onMessage);
115
+ }, [onRefreshCodex]);
116
+
117
+ const startAuth = () => {
118
+ setAuthStarted(true);
119
+ setAuthWaiting(true);
120
+ const popup = window.open(
121
+ "/auth/codex/start",
122
+ "codex-auth",
123
+ "popup=yes,width=640,height=780",
124
+ );
125
+ if (!popup || popup.closed) {
126
+ setAuthWaiting(false);
127
+ window.location.href = "/auth/codex/start";
128
+ return;
129
+ }
130
+ if (popupPollRef.current) clearInterval(popupPollRef.current);
131
+ popupPollRef.current = setInterval(() => {
132
+ if (popup.closed) {
133
+ clearInterval(popupPollRef.current);
134
+ popupPollRef.current = null;
135
+ setAuthWaiting(false);
136
+ }
137
+ }, 500);
138
+ };
139
+
140
+ const completeAuth = async () => {
141
+ if (!manualInput.trim() || exchanging) return;
142
+ setExchanging(true);
143
+ try {
144
+ const result = await exchangeCodexOAuth(manualInput.trim());
145
+ if (!result.ok)
146
+ throw new Error(result.error || "Codex OAuth exchange failed");
147
+ setManualInput("");
148
+ showToast("Codex connected", "success");
149
+ setAuthStarted(false);
150
+ setAuthWaiting(false);
151
+ await onRefreshCodex();
152
+ } catch (err) {
153
+ showToast(err.message || "Codex OAuth exchange failed", "error");
154
+ } finally {
155
+ setExchanging(false);
156
+ }
157
+ };
158
+
159
+ const handleDisconnect = async () => {
160
+ const result = await disconnectCodex();
161
+ if (!result.ok) {
162
+ showToast(result.error || "Failed to disconnect Codex", "error");
163
+ return;
164
+ }
165
+ showToast("Codex disconnected", "success");
166
+ setAuthStarted(false);
167
+ setAuthWaiting(false);
168
+ setManualInput("");
169
+ await onRefreshCodex();
170
+ };
171
+
172
+ return html`
173
+ <div class="space-y-2">
174
+ <div class="flex items-center justify-between">
175
+ <span class="text-xs text-gray-400">Codex OAuth</span>
176
+ ${codexStatus.connected
177
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
178
+ : html`<${Badge} tone="warning">Not connected</${Badge}>`}
179
+ </div>
180
+ ${codexStatus.connected
181
+ ? html`
182
+ <div class="flex gap-2">
183
+ <button
184
+ onclick=${startAuth}
185
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary"
186
+ >
187
+ Reconnect
188
+ </button>
189
+ <button
190
+ onclick=${handleDisconnect}
191
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost"
192
+ >
193
+ Disconnect
194
+ </button>
195
+ </div>
196
+ `
197
+ : !authStarted
198
+ ? html`
199
+ <button
200
+ onclick=${startAuth}
201
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
202
+ >
203
+ Connect Codex OAuth
204
+ </button>
205
+ `
206
+ : html`
207
+ <div class="flex items-center justify-between gap-2">
208
+ <p class="text-xs text-gray-500">
209
+ ${authWaiting
210
+ ? "Complete login in the popup, then paste the redirect URL."
211
+ : "Paste the redirect URL from your browser to finish connecting."}
212
+ </p>
213
+ <button
214
+ onclick=${startAuth}
215
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
216
+ >
217
+ Restart
218
+ </button>
219
+ </div>
220
+ `}
221
+ ${!codexStatus.connected && authStarted
222
+ ? html`
223
+ <p class="text-xs text-gray-500">
224
+ After login, copy the full redirect URL (starts with
225
+ <code class="text-xs bg-black/30 px-1 rounded"
226
+ >http://localhost:1455/auth/callback</code
227
+ >) and paste it here.
228
+ </p>
229
+ <input
230
+ type="text"
231
+ value=${manualInput}
232
+ onInput=${(e) => setManualInput(e.target.value)}
233
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
234
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
235
+ />
236
+ <${ActionButton}
237
+ onClick=${completeAuth}
238
+ disabled=${!manualInput.trim() || exchanging}
239
+ loading=${exchanging}
240
+ tone="primary"
241
+ size="sm"
242
+ idleLabel="Complete Codex OAuth"
243
+ loadingLabel="Completing..."
244
+ className="text-xs font-medium px-3 py-1.5"
245
+ />
246
+ `
247
+ : null}
248
+ </div>
249
+ `;
250
+ };
251
+
252
+ export const ProviderAuthCard = ({
253
+ provider,
254
+ authProfiles,
255
+ authOrder,
256
+ codexStatus,
257
+ onEditProfile,
258
+ onEditAuthOrder,
259
+ getProfileValue,
260
+ getEffectiveOrder,
261
+ onRefreshCodex,
262
+ }) => {
263
+ const meta = getProviderMeta(provider);
264
+ const credentialModes = meta.modes.filter((m) => !m.isCodexOauth);
265
+ const hasMultipleModes = credentialModes.length > 1;
266
+ const showsInlineOauthStatus = meta.modes.some((m) => m.isCodexOauth);
267
+
268
+ const effectiveOrder = getEffectiveOrder(provider);
269
+ const activeProfileId = effectiveOrder?.[0] || null;
270
+
271
+ const isConnected =
272
+ credentialModes.some((mode) => {
273
+ const profileId = resolveProfileId(mode, provider);
274
+ const val = getProfileValue(profileId);
275
+ return !!(val?.key || val?.token || val?.access);
276
+ }) || (provider === "openai-codex" && !!codexStatus?.connected);
277
+
278
+ const handleSetActive = (mode) => {
279
+ const profileId = resolveProfileId(mode, provider);
280
+ const allIds = credentialModes.map((m) => resolveProfileId(m, provider));
281
+ const ordered = [profileId, ...allIds.filter((id) => id !== profileId)];
282
+ onEditAuthOrder(provider, ordered);
283
+ };
284
+
285
+ return html`
286
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
287
+ <div class="flex items-center justify-between">
288
+ <h3 class="font-semibold text-sm">${meta.label}</h3>
289
+ ${showsInlineOauthStatus && credentialModes.length === 0
290
+ ? null
291
+ : isConnected
292
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
293
+ : html`<${Badge} tone="warning">Not configured</${Badge}>`}
294
+ </div>
295
+ ${credentialModes.map((mode) => {
296
+ const profileId = resolveProfileId(mode, provider);
297
+ const profileProvider = mode.provider || provider;
298
+ const currentValue = getProfileValue(profileId);
299
+ const fieldValue = currentValue?.[mode.field] || "";
300
+ const isActive =
301
+ !hasMultipleModes ||
302
+ activeProfileId === profileId ||
303
+ (!activeProfileId && mode === credentialModes[0]);
304
+
305
+ return html`
306
+ <div class="space-y-1.5">
307
+ <div class="flex items-center gap-2">
308
+ <label class="text-xs font-medium text-gray-400"
309
+ >${mode.label}</label
310
+ >
311
+ ${hasMultipleModes && isActive
312
+ ? html`<${Badge} tone="cyan">Active</${Badge}>`
313
+ : null}
314
+ ${hasMultipleModes && !isActive && fieldValue
315
+ ? html`<button
316
+ onclick=${() => handleSetActive(mode)}
317
+ class="text-xs px-1.5 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
318
+ >
319
+ Set active
320
+ </button>`
321
+ : null}
322
+ ${mode.url && !fieldValue
323
+ ? html`<a
324
+ href=${mode.url}
325
+ target="_blank"
326
+ class="text-xs hover:underline"
327
+ style="color: var(--accent-link)"
328
+ >Get</a
329
+ >`
330
+ : null}
331
+ </div>
332
+ <${SecretInput}
333
+ value=${fieldValue}
334
+ onInput=${(e) => {
335
+ const newVal = e.target.value;
336
+ const cred = {
337
+ type: mode.id,
338
+ provider: profileProvider,
339
+ [mode.field]: newVal,
340
+ };
341
+ if (currentValue?.expires) cred.expires = currentValue.expires;
342
+ onEditProfile(profileId, cred);
343
+ if (hasMultipleModes && newVal && !isActive) {
344
+ handleSetActive(mode);
345
+ }
346
+ }}
347
+ placeholder=${mode.placeholder || ""}
348
+ isSecret=${true}
349
+ inputClass="flex-1 w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
350
+ />
351
+ ${mode.hint
352
+ ? html`<p class="text-xs text-gray-600">${mode.hint}</p>`
353
+ : null}
354
+ </div>
355
+ `;
356
+ })}
357
+ ${meta.modes.some((m) => m.isCodexOauth)
358
+ ? html`
359
+ <div class="border border-border rounded-lg p-3">
360
+ <${CodexOAuthSection}
361
+ codexStatus=${codexStatus}
362
+ onRefreshCodex=${onRefreshCodex}
363
+ />
364
+ </div>
365
+ `
366
+ : null}
367
+ </div>
368
+ `;
369
+ };
@@ -0,0 +1,262 @@
1
+ import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchModels,
4
+ fetchModelsConfig,
5
+ saveModelsConfig,
6
+ fetchCodexStatus,
7
+ disconnectCodex,
8
+ } from "../../lib/api.js";
9
+ import { showToast } from "../toast.js";
10
+
11
+ let kModelsTabCache = null;
12
+
13
+ export const useModels = () => {
14
+ const [catalog, setCatalog] = useState(() => kModelsTabCache?.catalog || []);
15
+ const [primary, setPrimary] = useState(() => kModelsTabCache?.primary || "");
16
+ const [configuredModels, setConfiguredModels] = useState(
17
+ () => kModelsTabCache?.configuredModels || {},
18
+ );
19
+ const [authProfiles, setAuthProfiles] = useState(
20
+ () => kModelsTabCache?.authProfiles || [],
21
+ );
22
+ const [authOrder, setAuthOrder] = useState(
23
+ () => kModelsTabCache?.authOrder || {},
24
+ );
25
+ const [codexStatus, setCodexStatus] = useState(
26
+ () => kModelsTabCache?.codexStatus || { connected: false },
27
+ );
28
+ const [loading, setLoading] = useState(() => !kModelsTabCache);
29
+ const [saving, setSaving] = useState(false);
30
+ const [ready, setReady] = useState(() => !!kModelsTabCache);
31
+ const [error, setError] = useState("");
32
+
33
+ const [profileEdits, setProfileEdits] = useState({});
34
+ const [orderEdits, setOrderEdits] = useState({});
35
+
36
+ const savedPrimaryRef = useRef(kModelsTabCache?.primary || "");
37
+ const savedConfiguredRef = useRef(kModelsTabCache?.configuredModels || {});
38
+
39
+ const updateCache = useCallback((patch) => {
40
+ kModelsTabCache = { ...(kModelsTabCache || {}), ...patch };
41
+ }, []);
42
+
43
+ const refresh = useCallback(async () => {
44
+ if (!ready) setLoading(true);
45
+ setError("");
46
+ try {
47
+ const [catalogResult, configResult, codex] = await Promise.all([
48
+ fetchModels(),
49
+ fetchModelsConfig(),
50
+ fetchCodexStatus(),
51
+ ]);
52
+ const catalogModels = Array.isArray(catalogResult.models)
53
+ ? catalogResult.models
54
+ : [];
55
+ setCatalog(catalogModels);
56
+ const p = configResult.primary || "";
57
+ const cm = configResult.configuredModels || {};
58
+ const ap = configResult.authProfiles || [];
59
+ const ao = configResult.authOrder || {};
60
+ setPrimary(p);
61
+ setConfiguredModels(cm);
62
+ setAuthProfiles(ap);
63
+ setAuthOrder(ao);
64
+ setCodexStatus(codex || { connected: false });
65
+ setProfileEdits({});
66
+ setOrderEdits({});
67
+ savedPrimaryRef.current = p;
68
+ savedConfiguredRef.current = cm;
69
+ updateCache({
70
+ catalog: catalogModels,
71
+ primary: p,
72
+ configuredModels: cm,
73
+ authProfiles: ap,
74
+ authOrder: ao,
75
+ codexStatus: codex || { connected: false },
76
+ });
77
+ if (!catalogModels.length) setError("No models found");
78
+ } catch (err) {
79
+ setError("Failed to load model settings");
80
+ showToast(`Failed to load model settings: ${err.message}`, "error");
81
+ } finally {
82
+ setReady(true);
83
+ setLoading(false);
84
+ }
85
+ }, [ready, updateCache]);
86
+
87
+ useEffect(() => {
88
+ refresh();
89
+ }, []);
90
+
91
+ const modelConfigDirty =
92
+ primary !== savedPrimaryRef.current ||
93
+ JSON.stringify(configuredModels) !==
94
+ JSON.stringify(savedConfiguredRef.current);
95
+
96
+ const authDirty = (() => {
97
+ const hasProfileChanges = Object.entries(profileEdits).some(
98
+ ([id, cred]) => {
99
+ const existing = authProfiles.find((p) => p.id === id);
100
+ const newVal = cred?.key || cred?.token || cred?.access || "";
101
+ const oldVal =
102
+ existing?.key || existing?.token || existing?.access || "";
103
+ return newVal !== oldVal && newVal !== "";
104
+ },
105
+ );
106
+ const hasOrderChanges = Object.entries(orderEdits).some(
107
+ ([provider, order]) => {
108
+ const existing = authOrder[provider];
109
+ return JSON.stringify(order) !== JSON.stringify(existing);
110
+ },
111
+ );
112
+ return hasProfileChanges || hasOrderChanges;
113
+ })();
114
+
115
+ const isDirty = modelConfigDirty || authDirty;
116
+
117
+ const addModel = useCallback(
118
+ (modelKey) => {
119
+ if (!modelKey) return;
120
+ setConfiguredModels((prev) => {
121
+ const next = { ...prev, [modelKey]: {} };
122
+ updateCache({ configuredModels: next });
123
+ return next;
124
+ });
125
+ },
126
+ [updateCache],
127
+ );
128
+
129
+ const removeModel = useCallback(
130
+ (modelKey) => {
131
+ setConfiguredModels((prev) => {
132
+ const next = { ...prev };
133
+ delete next[modelKey];
134
+ updateCache({ configuredModels: next });
135
+ return next;
136
+ });
137
+ if (primary === modelKey) {
138
+ const remaining = Object.keys(configuredModels).filter(
139
+ (k) => k !== modelKey,
140
+ );
141
+ const newPrimary = remaining[0] || "";
142
+ setPrimary(newPrimary);
143
+ updateCache({ primary: newPrimary });
144
+ }
145
+ },
146
+ [primary, configuredModels, updateCache],
147
+ );
148
+
149
+ const setPrimaryModel = useCallback(
150
+ (modelKey) => {
151
+ setPrimary(modelKey);
152
+ updateCache({ primary: modelKey });
153
+ },
154
+ [updateCache],
155
+ );
156
+
157
+ const editProfile = useCallback((profileId, credential) => {
158
+ setProfileEdits((prev) => ({ ...prev, [profileId]: credential }));
159
+ }, []);
160
+
161
+ const editAuthOrder = useCallback((provider, orderedIds) => {
162
+ setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds }));
163
+ }, []);
164
+
165
+ const getProfileValue = useCallback(
166
+ (profileId) => {
167
+ if (profileEdits[profileId] !== undefined) return profileEdits[profileId];
168
+ const existing = authProfiles.find((p) => p.id === profileId);
169
+ return existing || null;
170
+ },
171
+ [profileEdits, authProfiles],
172
+ );
173
+
174
+ const getEffectiveOrder = useCallback(
175
+ (provider) => {
176
+ if (orderEdits[provider] !== undefined) return orderEdits[provider];
177
+ return authOrder[provider] || null;
178
+ },
179
+ [orderEdits, authOrder],
180
+ );
181
+
182
+ const cancelChanges = useCallback(() => {
183
+ const savedPrimary = savedPrimaryRef.current || "";
184
+ const savedConfigured = savedConfiguredRef.current || {};
185
+ setPrimary(savedPrimary);
186
+ setConfiguredModels(savedConfigured);
187
+ setProfileEdits({});
188
+ setOrderEdits({});
189
+ updateCache({
190
+ primary: savedPrimary,
191
+ configuredModels: savedConfigured,
192
+ });
193
+ }, [updateCache]);
194
+
195
+ const saveAll = useCallback(async () => {
196
+ if (saving) return;
197
+ setSaving(true);
198
+ try {
199
+ const changedProfiles = Object.entries(profileEdits)
200
+ .filter(([, cred]) => {
201
+ const val = cred?.key || cred?.token || cred?.access || "";
202
+ return val !== "";
203
+ })
204
+ .map(([id, cred]) => ({ id, ...cred }));
205
+
206
+ const result = await saveModelsConfig({
207
+ primary,
208
+ configuredModels,
209
+ profiles: changedProfiles.length > 0 ? changedProfiles : undefined,
210
+ authOrder:
211
+ Object.keys(orderEdits).length > 0 ? orderEdits : undefined,
212
+ });
213
+ if (!result.ok)
214
+ throw new Error(result.error || "Failed to save config");
215
+ showToast("Changes saved", "success");
216
+ if (result.syncWarning) {
217
+ showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning");
218
+ }
219
+ await refresh();
220
+ } catch (err) {
221
+ showToast(err.message || "Failed to save changes", "error");
222
+ } finally {
223
+ setSaving(false);
224
+ }
225
+ }, [saving, primary, configuredModels, profileEdits, orderEdits, refresh]);
226
+
227
+ const refreshCodexStatus = useCallback(async () => {
228
+ try {
229
+ const codex = await fetchCodexStatus();
230
+ setCodexStatus(codex || { connected: false });
231
+ updateCache({ codexStatus: codex || { connected: false } });
232
+ } catch {
233
+ setCodexStatus({ connected: false });
234
+ updateCache({ codexStatus: { connected: false } });
235
+ }
236
+ }, [updateCache]);
237
+
238
+ return {
239
+ catalog,
240
+ primary,
241
+ configuredModels,
242
+ authProfiles,
243
+ authOrder,
244
+ codexStatus,
245
+ loading,
246
+ saving,
247
+ ready,
248
+ error,
249
+ isDirty,
250
+ refresh,
251
+ addModel,
252
+ removeModel,
253
+ setPrimaryModel,
254
+ editProfile,
255
+ editAuthOrder,
256
+ getProfileValue,
257
+ getEffectiveOrder,
258
+ cancelChanges,
259
+ saveAll,
260
+ refreshCodexStatus,
261
+ };
262
+ };
@@ -0,0 +1,35 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { FileViewer } from "../file-viewer/index.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const BrowseRoute = ({
8
+ activeBrowsePath = "",
9
+ browseView = "edit",
10
+ lineTarget = 0,
11
+ lineEndTarget = 0,
12
+ selectedBrowsePath = "",
13
+ onNavigateToBrowseFile = () => {},
14
+ onEditSelectedBrowseFile = () => {},
15
+ onClearSelection = () => {},
16
+ }) => html`
17
+ <div class="w-full">
18
+ <${FileViewer}
19
+ filePath=${activeBrowsePath}
20
+ isPreviewOnly=${false}
21
+ browseView=${browseView}
22
+ lineTarget=${lineTarget}
23
+ lineEndTarget=${lineEndTarget}
24
+ onRequestEdit=${(targetPath) => {
25
+ const normalizedTargetPath = String(targetPath || "");
26
+ if (normalizedTargetPath && normalizedTargetPath !== selectedBrowsePath) {
27
+ onNavigateToBrowseFile(normalizedTargetPath, { view: "edit" });
28
+ return;
29
+ }
30
+ onEditSelectedBrowseFile();
31
+ }}
32
+ onRequestClearSelection=${onClearSelection}
33
+ />
34
+ </div>
35
+ `;
@@ -0,0 +1,21 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { DoctorTab } from "../doctor/index.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const DoctorRoute = ({ onNavigateToBrowseFile = () => {} }) => html`
8
+ <div class="pt-4">
9
+ <${DoctorTab}
10
+ isActive=${true}
11
+ onOpenFile=${(relativePath, options = {}) => {
12
+ const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`;
13
+ onNavigateToBrowseFile(browsePath, {
14
+ view: "edit",
15
+ ...(options.line ? { line: options.line } : {}),
16
+ ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}),
17
+ });
18
+ }}
19
+ />
20
+ </div>
21
+ `;
@@ -0,0 +1,11 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Envars } from "../envars.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const EnvarsRoute = ({ onRestartRequired = () => {} }) => html`
8
+ <div class="pt-4">
9
+ <${Envars} onRestartRequired=${onRestartRequired} />
10
+ </div>
11
+ `;