@chrysb/alphaclaw 0.7.2-beta.7 → 0.8.1-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.
@@ -240,87 +240,105 @@ const App = () => {
240
240
  <span style="color: var(--accent)">alpha</span>claw
241
241
  </span>
242
242
  </div>
243
- <div
244
- class="app-content-pane browse-pane"
245
- style=${{ display: browseState.isBrowseRoute ? "block" : "none" }}
246
- >
247
- <${BrowseRoute}
248
- activeBrowsePath=${browseState.activeBrowsePath}
249
- browseView=${browseState.browseViewerMode}
250
- lineTarget=${browseState.browseLineTarget}
251
- lineEndTarget=${browseState.browseLineEndTarget}
252
- selectedBrowsePath=${browseState.selectedBrowsePath}
253
- onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
254
- onEditSelectedBrowseFile=${() =>
255
- setLocation(
256
- browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {
257
- view: "edit",
258
- }),
259
- )}
260
- onClearSelection=${() => {
261
- browseActions.clearBrowsePreview();
262
- setLocation("/browse");
263
- }}
264
- />
265
- </div>
266
- <div
267
- class="app-content-pane agents-pane"
268
- style=${{ display: isAgentsRoute ? "block" : "none" }}
269
- >
270
- <${AgentsRoute}
271
- agents=${agentsState.agents}
272
- loading=${agentsState.loading}
273
- saving=${agentsState.saving}
274
- agentsActions=${agentsActions}
275
- selectedAgentId=${selectedAgentId}
276
- activeTab=${agentDetailTab}
277
- onSelectAgent=${(agentId) => setLocation(`/agents/${encodeURIComponent(agentId)}`)}
278
- onSelectTab=${(tab) => {
279
- const safePath = tab && tab !== "overview"
280
- ? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}`
281
- : `/agents/${encodeURIComponent(selectedAgentId)}`;
282
- setLocation(safePath);
283
- }}
284
- onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
285
- onSetLocation=${setLocation}
286
- />
287
- </div>
288
- <div
289
- class="app-content-pane cron-pane"
290
- style=${{ display: isCronRoute ? "block" : "none" }}
291
- >
292
- <${CronRoute}
293
- jobId=${selectedCronJobId}
294
- onSetLocation=${setLocation}
295
- />
296
- </div>
297
- <div
298
- class="app-content-pane ac-fixed-header-pane"
299
- style=${{ display: isEnvarsRoute ? "block" : "none" }}
300
- >
301
- <${EnvarsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
302
- </div>
303
- <div
304
- class="app-content-pane ac-fixed-header-pane"
305
- style=${{ display: isModelsRoute ? "block" : "none" }}
306
- >
307
- <${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
308
- </div>
309
- <div
310
- class="app-content-pane"
311
- style=${{ display: isNodesRoute ? "block" : "none" }}
312
- >
313
- <${NodesRoute} onRestartRequired=${controllerActions.setRestartRequired} />
314
- </div>
315
- <div
316
- class="app-content-pane"
317
- onscroll=${shellActions.handlePaneScroll}
318
- style=${{ display: browseState.isBrowseRoute || isAgentsRoute || isCronRoute || isEnvarsRoute || isModelsRoute || isNodesRoute ? "none" : "block" }}
319
- >
243
+ ${browseState.isBrowseRoute
244
+ ? html`
245
+ <div class="app-content-pane browse-pane">
246
+ <${BrowseRoute}
247
+ activeBrowsePath=${browseState.activeBrowsePath}
248
+ browseView=${browseState.browseViewerMode}
249
+ lineTarget=${browseState.browseLineTarget}
250
+ lineEndTarget=${browseState.browseLineEndTarget}
251
+ selectedBrowsePath=${browseState.selectedBrowsePath}
252
+ onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
253
+ onEditSelectedBrowseFile=${() =>
254
+ setLocation(
255
+ browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {
256
+ view: "edit",
257
+ }),
258
+ )}
259
+ onClearSelection=${() => {
260
+ browseActions.clearBrowsePreview();
261
+ setLocation("/browse");
262
+ }}
263
+ />
264
+ </div>
265
+ `
266
+ : null}
267
+ ${isAgentsRoute
268
+ ? html`
269
+ <div class="app-content-pane agents-pane">
270
+ <${AgentsRoute}
271
+ agents=${agentsState.agents}
272
+ loading=${agentsState.loading}
273
+ saving=${agentsState.saving}
274
+ agentsActions=${agentsActions}
275
+ selectedAgentId=${selectedAgentId}
276
+ activeTab=${agentDetailTab}
277
+ onSelectAgent=${(agentId) =>
278
+ setLocation(`/agents/${encodeURIComponent(agentId)}`)}
279
+ onSelectTab=${(tab) => {
280
+ const safePath = tab && tab !== "overview"
281
+ ? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}`
282
+ : `/agents/${encodeURIComponent(selectedAgentId)}`;
283
+ setLocation(safePath);
284
+ }}
285
+ onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
286
+ onSetLocation=${setLocation}
287
+ />
288
+ </div>
289
+ `
290
+ : null}
291
+ ${isCronRoute
292
+ ? html`
293
+ <div class="app-content-pane cron-pane">
294
+ <${CronRoute}
295
+ jobId=${selectedCronJobId}
296
+ onSetLocation=${setLocation}
297
+ />
298
+ </div>
299
+ `
300
+ : null}
301
+ ${isEnvarsRoute
302
+ ? html`
303
+ <div class="app-content-pane ac-fixed-header-pane">
304
+ <${EnvarsRoute}
305
+ onRestartRequired=${controllerActions.setRestartRequired}
306
+ />
307
+ </div>
308
+ `
309
+ : null}
310
+ ${isModelsRoute
311
+ ? html`
312
+ <div class="app-content-pane ac-fixed-header-pane">
313
+ <${ModelsRoute}
314
+ onRestartRequired=${controllerActions.setRestartRequired}
315
+ />
316
+ </div>
317
+ `
318
+ : null}
319
+ ${isNodesRoute
320
+ ? html`
321
+ <div class="app-content-pane">
322
+ <${NodesRoute}
323
+ onRestartRequired=${controllerActions.setRestartRequired}
324
+ />
325
+ </div>
326
+ `
327
+ : null}
328
+ ${browseState.isBrowseRoute ||
329
+ isAgentsRoute ||
330
+ isCronRoute ||
331
+ isEnvarsRoute ||
332
+ isModelsRoute ||
333
+ isNodesRoute
334
+ ? null
335
+ : html`
336
+ <div
337
+ class="app-content-pane"
338
+ onscroll=${shellActions.handlePaneScroll}
339
+ >
320
340
  <div class="max-w-2xl w-full mx-auto">
321
- ${!browseState.isBrowseRoute && !isAgentsRoute && !isCronRoute && !isEnvarsRoute && !isModelsRoute && !isNodesRoute
322
- ? html`
323
- <${Switch}>
341
+ <${Switch}>
324
342
  <${Route} path="/general">
325
343
  <${GeneralRoute}
326
344
  statusData=${controllerState.sharedStatus}
@@ -412,10 +430,9 @@ const App = () => {
412
430
  <${RouteRedirect} to="/general" />
413
431
  </${Route}>
414
432
  </${Switch}>
415
- `
416
- : null}
417
433
  </div>
418
- </div>
434
+ </div>
435
+ `}
419
436
  <${ToastContainer}
420
437
  className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none"
421
438
  />
@@ -17,6 +17,7 @@ import {
17
17
  rejectPairing,
18
18
  } from "../../lib/api.js";
19
19
  import { showToast } from "../toast.js";
20
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
20
21
 
21
22
  const html = htm.bind(h);
22
23
 
@@ -43,29 +44,60 @@ export const AgentPairingSection = ({ agent = {} }) => {
43
44
  const pairingDelayedRefreshTimerRefs = useRef([]);
44
45
  const agentId = String(agent?.id || "").trim();
45
46
  const isDefaultAgent = !!agent?.default;
47
+ const {
48
+ data: bindingsPayload,
49
+ loading: bindingsLoading,
50
+ refresh: refreshBindingsPayload,
51
+ } = useCachedFetch(
52
+ `/api/agents/${encodeURIComponent(String(agentId || ""))}/bindings`,
53
+ () => fetchAgentBindings(agent.id),
54
+ {
55
+ enabled: Boolean(agentId),
56
+ maxAgeMs: 30000,
57
+ },
58
+ );
59
+ const {
60
+ data: channelsPayload,
61
+ loading: channelsLoading,
62
+ refresh: refreshChannelsPayload,
63
+ } = useCachedFetch("/api/channels/accounts", fetchChannelAccounts, {
64
+ maxAgeMs: 30000,
65
+ });
46
66
 
47
67
  const loadBindings = useCallback(async () => {
48
68
  setLoadingBindings(true);
49
69
  try {
50
- const [bindingsResult, channelsResult] = await Promise.all([
51
- fetchAgentBindings(agent.id),
52
- fetchChannelAccounts(),
70
+ const [nextBindingsPayload, nextChannelsPayload] = await Promise.all([
71
+ refreshBindingsPayload({ force: true }),
72
+ refreshChannelsPayload({ force: true }),
53
73
  ]);
54
- setBindings(Array.isArray(bindingsResult?.bindings) ? bindingsResult.bindings : []);
55
- setChannels(Array.isArray(channelsResult?.channels) ? channelsResult.channels : []);
74
+ setBindings(
75
+ Array.isArray(nextBindingsPayload?.bindings)
76
+ ? nextBindingsPayload.bindings
77
+ : [],
78
+ );
79
+ setChannels(
80
+ Array.isArray(nextChannelsPayload?.channels)
81
+ ? nextChannelsPayload.channels
82
+ : [],
83
+ );
56
84
  } catch {
57
85
  setBindings([]);
58
86
  setChannels([]);
59
87
  } finally {
60
88
  setLoadingBindings(false);
61
89
  }
62
- }, [agent.id]);
90
+ }, [refreshBindingsPayload, refreshChannelsPayload]);
63
91
 
64
92
  useEffect(() => {
65
- if (String(agent?.id || "").trim()) {
66
- loadBindings();
67
- }
68
- }, [agent.id, loadBindings]);
93
+ setBindings(
94
+ Array.isArray(bindingsPayload?.bindings) ? bindingsPayload.bindings : [],
95
+ );
96
+ setChannels(
97
+ Array.isArray(channelsPayload?.channels) ? channelsPayload.channels : [],
98
+ );
99
+ setLoadingBindings(Boolean(bindingsLoading || channelsLoading));
100
+ }, [bindingsLoading, bindingsPayload, channelsLoading, channelsPayload]);
69
101
 
70
102
  useEffect(() => {
71
103
  const handleBindingsChanged = (event) => {
@@ -207,8 +239,11 @@ export const AgentPairingSection = ({ agent = {} }) => {
207
239
  };
208
240
  });
209
241
  },
210
- 1000,
211
- { enabled: hasUnpaired && ownedAccounts.length > 0 },
242
+ 3000,
243
+ {
244
+ enabled: hasUnpaired && ownedAccounts.length > 0,
245
+ cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`,
246
+ },
212
247
  );
213
248
 
214
249
  const pending = pairingsPoll.data || [];
@@ -15,6 +15,7 @@ import {
15
15
  fetchChannelAccounts,
16
16
  updateChannelAccount,
17
17
  } from "../lib/api.js";
18
+ import { useCachedFetch } from "../hooks/use-cached-fetch.js";
18
19
  import {
19
20
  isImplicitDefaultAccount,
20
21
  resolveChannelAccountLabel,
@@ -140,31 +141,27 @@ export const Channels = ({
140
141
  onNavigate = () => {},
141
142
  onRefreshStatuses = () => {},
142
143
  }) => {
143
- const [channelAccounts, setChannelAccounts] = useState([]);
144
- const [loadingAccounts, setLoadingAccounts] = useState(true);
145
144
  const [saving, setSaving] = useState(false);
146
145
  const [createLoadingLabel, setCreateLoadingLabel] = useState("Creating...");
147
146
  const [menuOpenId, setMenuOpenId] = useState("");
148
147
  const [editingAccount, setEditingAccount] = useState(null);
149
148
  const [deletingAccount, setDeletingAccount] = useState(null);
149
+ const {
150
+ data: channelAccountsPayload,
151
+ loading: loadingAccounts,
152
+ refresh: refreshChannelAccounts,
153
+ } = useCachedFetch("/api/channels/accounts", fetchChannelAccounts, {
154
+ maxAgeMs: 30000,
155
+ });
156
+ const channelAccounts = Array.isArray(channelAccountsPayload?.channels)
157
+ ? channelAccountsPayload.channels
158
+ : [];
150
159
 
151
160
  const loadChannelAccounts = useCallback(async () => {
152
- setLoadingAccounts(true);
153
161
  try {
154
- const payload = await fetchChannelAccounts();
155
- setChannelAccounts(
156
- Array.isArray(payload?.channels) ? payload.channels : [],
157
- );
158
- } catch {
159
- setChannelAccounts([]);
160
- } finally {
161
- setLoadingAccounts(false);
162
- }
163
- }, []);
164
-
165
- useEffect(() => {
166
- loadChannelAccounts();
167
- }, [loadChannelAccounts]);
162
+ await refreshChannelAccounts({ force: true });
163
+ } catch {}
164
+ }, [refreshChannelAccounts]);
168
165
 
169
166
 
170
167
  const configuredChannelMap = useMemo(
@@ -7,6 +7,7 @@ import {
7
7
  } from "https://esm.sh/preact/hooks";
8
8
  import htm from "https://esm.sh/htm";
9
9
  import { fetchEnvVars, saveEnvVars } from "../lib/api.js";
10
+ import { useCachedFetch } from "../hooks/use-cached-fetch.js";
10
11
  import { showToast } from "./toast.js";
11
12
  import { SecretInput } from "./secret-input.js";
12
13
  import { PageHeader } from "./page-header.js";
@@ -315,24 +316,47 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
315
316
  const [showAllAiKeys, setShowAllAiKeys] = useState(false);
316
317
  const [newKey, setNewKey] = useState("");
317
318
  const baselineSignatureRef = useRef("[]");
319
+ const {
320
+ data: envPayload,
321
+ error: envPayloadError,
322
+ loading: envPayloadLoading,
323
+ refresh: refreshEnvPayload,
324
+ } = useCachedFetch("/api/env", fetchEnvVars, {
325
+ maxAgeMs: 30000,
326
+ });
318
327
 
319
- const load = useCallback(async () => {
320
- try {
321
- const data = await fetchEnvVars();
328
+ const applyEnvPayload = useCallback(
329
+ (data) => {
330
+ if (!data) return;
322
331
  const nextVars = sortCustomVarsAlphabetically(data.vars || []);
323
332
  baselineSignatureRef.current = getVarsSignature(nextVars);
324
333
  setVars(nextVars);
325
334
  setPendingCustomKeys([]);
326
335
  setReservedKeys(new Set(data.reservedKeys || []));
327
336
  onRestartRequired(!!data.restartRequired);
337
+ },
338
+ [onRestartRequired],
339
+ );
340
+
341
+ const load = useCallback(async () => {
342
+ try {
343
+ const data = await refreshEnvPayload({ force: true });
344
+ applyEnvPayload(data);
328
345
  } catch (err) {
329
346
  console.error("Failed to load env vars:", err);
330
347
  }
331
- }, []);
348
+ }, [applyEnvPayload, refreshEnvPayload]);
332
349
 
333
350
  useEffect(() => {
334
- load();
335
- }, [load]);
351
+ if (!envPayload) return;
352
+ if (dirty || saving) return;
353
+ applyEnvPayload(envPayload);
354
+ }, [applyEnvPayload, dirty, envPayload, saving]);
355
+
356
+ useEffect(() => {
357
+ if (!envPayloadError) return;
358
+ console.error("Failed to load env vars:", envPayloadError);
359
+ }, [envPayloadError]);
336
360
 
337
361
  useEffect(() => {
338
362
  setDirty(getVarsSignature(vars) !== baselineSignatureRef.current);
@@ -592,6 +616,18 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
592
616
  `;
593
617
  };
594
618
 
619
+ if (envPayloadLoading && !vars.length) {
620
+ return html`
621
+ <${PaneShell}
622
+ header=${html`<${PageHeader} title="Envars" />`}
623
+ >
624
+ <div class="bg-surface border border-border rounded-xl p-4 text-sm text-gray-500">
625
+ Loading environment variables...
626
+ </div>
627
+ </${PaneShell}>
628
+ `;
629
+ }
630
+
595
631
  return html`
596
632
  <${PaneShell}
597
633
  header=${html`
@@ -1,7 +1,7 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useState, useEffect } from "https://esm.sh/preact/hooks";
3
2
  import htm from "https://esm.sh/htm";
4
3
  import { fetchEnvVars } from "../lib/api.js";
4
+ import { useCachedFetch } from "../hooks/use-cached-fetch.js";
5
5
  import { Badge } from "./badge.js";
6
6
  import {
7
7
  kFeatureDefs,
@@ -23,17 +23,11 @@ const resolveFeatureStatus = (feature, envVars) => {
23
23
  };
24
24
 
25
25
  export const Features = ({ onSwitchTab }) => {
26
- const [envVars, setEnvVars] = useState([]);
27
- const [loaded, setLoaded] = useState(false);
28
-
29
- useEffect(() => {
30
- fetchEnvVars()
31
- .then((data) => {
32
- setEnvVars(data.vars || []);
33
- setLoaded(true);
34
- })
35
- .catch(() => setLoaded(true));
36
- }, []);
26
+ const { data, loading } = useCachedFetch("/api/env", fetchEnvVars, {
27
+ maxAgeMs: 30000,
28
+ });
29
+ const envVars = Array.isArray(data?.vars) ? data.vars : [];
30
+ const loaded = !loading;
37
31
 
38
32
  if (!loaded) return null;
39
33
 
@@ -62,8 +62,11 @@ export const useGeneralTab = ({
62
62
  const data = await fetchPairings();
63
63
  return data.pending || [];
64
64
  },
65
- 1000,
66
- { enabled: hasUnpaired && gatewayStatus === "running" },
65
+ 3000,
66
+ {
67
+ enabled: hasUnpaired && gatewayStatus === "running",
68
+ cacheKey: "/api/pairings",
69
+ },
67
70
  );
68
71
  const pending = pairingsPoll.data || [];
69
72
  const shouldPollDevices =
@@ -75,14 +78,16 @@ export const useGeneralTab = ({
75
78
  setCliAutoApproveComplete(data?.cliAutoApproveComplete === true);
76
79
  return data.pending || [];
77
80
  },
78
- 2000,
79
- { enabled: shouldPollDevices },
81
+ 5000,
82
+ {
83
+ enabled: shouldPollDevices,
84
+ cacheKey: "/api/devices",
85
+ },
80
86
  );
81
87
  const devicePending = devicePoll.data || [];
82
88
 
83
89
  useEffect(() => {
84
90
  if (!isActive) return;
85
- onRefreshStatuses();
86
91
  pairingsPoll.refresh();
87
92
  if (shouldPollDevices) {
88
93
  devicePoll.refresh();
@@ -6,34 +6,38 @@ import {
6
6
  startGmailWatch,
7
7
  stopGmailWatch,
8
8
  } from "../../lib/api.js";
9
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
9
10
 
10
11
  export const useGmailWatch = ({ gatewayStatus, accounts = [] }) => {
11
- const [loading, setLoading] = useState(true);
12
- const [config, setConfig] = useState(null);
13
12
  const [busyByAccountId, setBusyByAccountId] = useState({});
14
13
  const [savingClient, setSavingClient] = useState(false);
14
+ const accountSignature = useMemo(
15
+ () =>
16
+ accounts
17
+ .map((entry) => String(entry?.id || "").trim())
18
+ .filter(Boolean)
19
+ .sort()
20
+ .join("|"),
21
+ [accounts],
22
+ );
23
+ const {
24
+ data: config,
25
+ loading,
26
+ refresh: refreshCachedConfig,
27
+ } = useCachedFetch("/api/gmail/config", fetchGmailConfig, {
28
+ enabled: gatewayStatus === "running",
29
+ maxAgeMs: 30000,
30
+ });
15
31
 
16
32
  const refresh = useCallback(async () => {
17
- setLoading(true);
18
- try {
19
- const nextConfig = await fetchGmailConfig();
20
- setConfig(nextConfig);
21
- return nextConfig;
22
- } finally {
23
- setLoading(false);
24
- }
25
- }, []);
26
-
27
- useEffect(() => {
28
- if (gatewayStatus !== "running") return;
29
- refresh();
30
- }, [gatewayStatus, refresh]);
33
+ return refreshCachedConfig({ force: true });
34
+ }, [refreshCachedConfig]);
31
35
 
32
36
  useEffect(() => {
33
37
  if (gatewayStatus !== "running") return;
34
38
  if (!accounts.length) return;
35
- refresh();
36
- }, [accounts, gatewayStatus, refresh]);
39
+ refresh().catch(() => {});
40
+ }, [accountSignature, accounts.length, gatewayStatus, refresh]);
37
41
 
38
42
  const watchByAccountId = useMemo(() => {
39
43
  const map = new Map();
@@ -1,34 +1,34 @@
1
- import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
1
+ import { useCallback, useEffect, useMemo, useRef } from "https://esm.sh/preact/hooks";
2
2
  import { fetchGoogleAccounts } from "../../lib/api.js";
3
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
3
4
 
4
5
  export const useGoogleAccounts = ({ gatewayStatus }) => {
5
- const [accounts, setAccounts] = useState([]);
6
- const [loading, setLoading] = useState(true);
7
- const [hasCompanyCredentials, setHasCompanyCredentials] = useState(false);
8
- const [hasPersonalCredentials, setHasPersonalCredentials] = useState(false);
6
+ const hasRefreshedAfterGatewayRunningRef = useRef(false);
7
+ const { data, loading, refresh } = useCachedFetch(
8
+ "/api/google/accounts",
9
+ fetchGoogleAccounts,
10
+ { maxAgeMs: 30000 },
11
+ );
9
12
 
10
- const refreshAccounts = useCallback(async () => {
11
- setLoading(true);
12
- try {
13
- const data = await fetchGoogleAccounts();
14
- if (data.ok) {
15
- setAccounts(Array.isArray(data.accounts) ? data.accounts : []);
16
- setHasCompanyCredentials(Boolean(data.hasCompanyCredentials));
17
- setHasPersonalCredentials(Boolean(data.hasPersonalCredentials));
18
- }
19
- } finally {
20
- setLoading(false);
21
- }
22
- }, []);
13
+ const accounts = useMemo(
14
+ () => (Array.isArray(data?.accounts) ? data.accounts : []),
15
+ [data?.accounts],
16
+ );
17
+ const hasCompanyCredentials = Boolean(data?.hasCompanyCredentials);
18
+ const hasPersonalCredentials = Boolean(data?.hasPersonalCredentials);
23
19
 
24
- useEffect(() => {
25
- refreshAccounts();
26
- }, [refreshAccounts]);
20
+ const refreshAccounts = useCallback(async () => {
21
+ return refresh({ force: true });
22
+ }, [refresh]);
27
23
 
28
24
  useEffect(() => {
29
- if (gatewayStatus === "running") {
30
- refreshAccounts();
25
+ if (gatewayStatus !== "running") {
26
+ hasRefreshedAfterGatewayRunningRef.current = false;
27
+ return;
31
28
  }
29
+ if (hasRefreshedAfterGatewayRunningRef.current) return;
30
+ hasRefreshedAfterGatewayRunningRef.current = true;
31
+ refreshAccounts().catch(() => {});
32
32
  }, [gatewayStatus, refreshAccounts]);
33
33
 
34
34
  return {
@@ -7,6 +7,7 @@ import {
7
7
  disconnectCodex,
8
8
  } from "../../lib/api.js";
9
9
  import { showToast } from "../toast.js";
10
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
10
11
 
11
12
  let kModelsTabCache = null;
12
13
  const getCredentialValue = (value) =>
@@ -14,6 +15,7 @@ const getCredentialValue = (value) =>
14
15
 
15
16
  export const useModels = (agentId) => {
16
17
  const isScoped = !!agentId;
18
+ const normalizedAgentId = String(agentId || "").trim();
17
19
  const useCache = !isScoped;
18
20
  const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []);
19
21
  const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || "");
@@ -43,15 +45,29 @@ export const useModels = (agentId) => {
43
45
  const updateCache = useCallback((patch) => {
44
46
  if (!isScoped) kModelsTabCache = { ...(kModelsTabCache || {}), ...patch };
45
47
  }, [isScoped]);
48
+ const modelsConfigCacheKey = normalizedAgentId
49
+ ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}`
50
+ : "/api/models/config";
51
+ const catalogFetchState = useCachedFetch("/api/models", fetchModels, {
52
+ maxAgeMs: 30000,
53
+ });
54
+ const configFetchState = useCachedFetch(
55
+ modelsConfigCacheKey,
56
+ () => fetchModelsConfig(isScoped ? { agentId } : undefined),
57
+ { maxAgeMs: 30000 },
58
+ );
59
+ const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, {
60
+ maxAgeMs: 15000,
61
+ });
46
62
 
47
63
  const refresh = useCallback(async () => {
48
64
  if (!ready) setLoading(true);
49
65
  setError("");
50
66
  try {
51
67
  const [catalogResult, configResult, codex] = await Promise.all([
52
- fetchModels(),
53
- fetchModelsConfig(isScoped ? { agentId } : undefined),
54
- fetchCodexStatus(),
68
+ catalogFetchState.refresh({ force: true }),
69
+ configFetchState.refresh({ force: true }),
70
+ codexFetchState.refresh({ force: true }),
55
71
  ]);
56
72
  const catalogModels = Array.isArray(catalogResult.models)
57
73
  ? catalogResult.models
@@ -86,7 +102,7 @@ export const useModels = (agentId) => {
86
102
  setReady(true);
87
103
  setLoading(false);
88
104
  }
89
- }, [ready, updateCache, agentId, isScoped]);
105
+ }, [catalogFetchState, codexFetchState, configFetchState, ready, updateCache, agentId, isScoped]);
90
106
 
91
107
  useEffect(() => {
92
108
  refresh();
@@ -1,7 +1,7 @@
1
1
  import { usePolling } from "../../../hooks/usePolling.js";
2
2
  import { fetchNodesStatus } from "../../../lib/api.js";
3
3
 
4
- const kNodesPollIntervalMs = 3000;
4
+ const kNodesPollIntervalMs = 10000;
5
5
 
6
6
  export const useConnectedNodes = ({ enabled = true } = {}) => {
7
7
  const poll = usePolling(
@@ -12,7 +12,7 @@ export const useConnectedNodes = ({ enabled = true } = {}) => {
12
12
  return { nodes, pending };
13
13
  },
14
14
  kNodesPollIntervalMs,
15
- { enabled },
15
+ { enabled, cacheKey: "/api/nodes" },
16
16
  );
17
17
 
18
18
  return {
@@ -1,26 +1,30 @@
1
1
  import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
2
  import { fetchNodeConnectInfo } from "../../lib/api.js";
3
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
3
4
  import { showToast } from "../toast.js";
4
5
  import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js";
5
6
 
6
7
  export const useNodesTab = () => {
7
8
  const connectedNodesState = useConnectedNodes({ enabled: true });
8
9
  const [wizardVisible, setWizardVisible] = useState(false);
9
- const [connectInfo, setConnectInfo] = useState(null);
10
10
  const [refreshingNodes, setRefreshingNodes] = useState(false);
11
+ const {
12
+ data: connectInfo,
13
+ error: connectInfoError,
14
+ } = useCachedFetch("/api/nodes/connect-info", fetchNodeConnectInfo, {
15
+ maxAgeMs: 60000,
16
+ });
11
17
  const pairedNodes = Array.isArray(connectedNodesState.nodes)
12
18
  ? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)
13
19
  : [];
14
20
 
15
21
  useEffect(() => {
16
- fetchNodeConnectInfo()
17
- .then((result) => {
18
- setConnectInfo(result || null);
19
- })
20
- .catch((error) => {
21
- showToast(error.message || "Could not load node connect command", "error");
22
- });
23
- }, []);
22
+ if (!connectInfoError) return;
23
+ showToast(
24
+ connectInfoError.message || "Could not load node connect command",
25
+ "error",
26
+ );
27
+ }, [connectInfoError]);
24
28
 
25
29
  const refreshNodes = useCallback(async () => {
26
30
  if (refreshingNodes) return;
@@ -10,6 +10,7 @@ import {
10
10
  fetchWatchdogStatus,
11
11
  fetchDoctorStatus,
12
12
  updateOpenclaw,
13
+ subscribeStatusEvents,
13
14
  } from "../lib/api.js";
14
15
  import { shouldRequireRestartForBrowsePath } from "../lib/browse-restart-policy.js";
15
16
  import { usePolling } from "./usePolling.js";
@@ -28,19 +29,28 @@ export const useAppShellController = ({ location = "" } = {}) => {
28
29
  const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);
29
30
  const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);
30
31
  const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
32
+ const [statusStreamConnected, setStatusStreamConnected] = useState(false);
33
+ const [statusStreamStatus, setStatusStreamStatus] = useState(null);
34
+ const [statusStreamWatchdog, setStatusStreamWatchdog] = useState(null);
35
+ const [statusStreamDoctor, setStatusStreamDoctor] = useState(null);
31
36
 
32
37
  const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
33
- enabled: onboarded === true,
38
+ enabled: onboarded === true && !statusStreamConnected,
39
+ cacheKey: "/api/status",
34
40
  });
35
41
  const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
36
- enabled: onboarded === true,
42
+ enabled: onboarded === true && !statusStreamConnected,
43
+ cacheKey: "/api/watchdog/status",
37
44
  });
38
45
  const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
39
- enabled: onboarded === true,
46
+ enabled: onboarded === true && !statusStreamConnected,
47
+ cacheKey: "/api/doctor/status",
40
48
  });
41
- const sharedStatus = sharedStatusPoll.data || null;
42
- const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null;
43
- const sharedDoctorStatus = sharedDoctorPoll.data?.status || null;
49
+ const sharedStatus = statusStreamStatus || sharedStatusPoll.data || null;
50
+ const sharedWatchdogStatus =
51
+ statusStreamWatchdog || sharedWatchdogPoll.data?.status || null;
52
+ const sharedDoctorStatus =
53
+ statusStreamDoctor || sharedDoctorPoll.data?.status || null;
44
54
  const isAnyRestartRequired = restartRequired || browseRestartRequired;
45
55
 
46
56
  const refreshSharedStatuses = useCallback(() => {
@@ -58,6 +68,49 @@ export const useAppShellController = ({ location = "" } = {}) => {
58
68
  .catch(() => {});
59
69
  }, []);
60
70
 
71
+ useEffect(() => {
72
+ if (onboarded !== true) return;
73
+ let disposed = false;
74
+ const startStream = () => {
75
+ if (disposed) return;
76
+ try {
77
+ return subscribeStatusEvents({
78
+ onOpen: () => {
79
+ if (disposed) return;
80
+ setStatusStreamConnected(true);
81
+ },
82
+ onMessage: (payload = {}) => {
83
+ if (disposed) return;
84
+ if (payload.status && typeof payload.status === "object") {
85
+ setStatusStreamStatus(payload.status);
86
+ }
87
+ if (payload.watchdogStatus && typeof payload.watchdogStatus === "object") {
88
+ setStatusStreamWatchdog(payload.watchdogStatus);
89
+ }
90
+ if (payload.doctorStatus && typeof payload.doctorStatus === "object") {
91
+ setStatusStreamDoctor(payload.doctorStatus);
92
+ }
93
+ },
94
+ onError: () => {
95
+ if (disposed) return;
96
+ setStatusStreamConnected(false);
97
+ },
98
+ });
99
+ } catch {
100
+ setStatusStreamConnected(false);
101
+ return null;
102
+ }
103
+ };
104
+ let cleanup = startStream();
105
+ return () => {
106
+ disposed = true;
107
+ setStatusStreamConnected(false);
108
+ if (typeof cleanup === "function") {
109
+ cleanup();
110
+ }
111
+ };
112
+ }, [onboarded]);
113
+
61
114
  useEffect(() => {
62
115
  if (!onboarded) return;
63
116
  let active = true;
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { cachedFetch, getCached } from "../lib/api-cache.js";
3
+
4
+ export const useCachedFetch = (
5
+ key,
6
+ fetcher,
7
+ {
8
+ enabled = true,
9
+ maxAgeMs = 15000,
10
+ staleWhileRevalidate = true,
11
+ } = {},
12
+ ) => {
13
+ const normalizedKey = useMemo(() => String(key || ""), [key]);
14
+ const initialCachedData = useMemo(() => getCached(normalizedKey), [normalizedKey]);
15
+ const [data, setData] = useState(initialCachedData);
16
+ const [loading, setLoading] = useState(initialCachedData === null);
17
+ const [error, setError] = useState(null);
18
+
19
+ useEffect(() => {
20
+ setData(getCached(normalizedKey));
21
+ }, [normalizedKey]);
22
+
23
+ const refresh = useCallback(
24
+ async ({ force = false } = {}) => {
25
+ if (!enabled) return getCached(normalizedKey);
26
+ if (getCached(normalizedKey) === null) {
27
+ setLoading(true);
28
+ }
29
+ try {
30
+ const next = await cachedFetch(normalizedKey, fetcher, {
31
+ maxAgeMs,
32
+ force,
33
+ staleWhileRevalidate,
34
+ onRevalidate: (revalidatedData) => {
35
+ setData(revalidatedData);
36
+ setError(null);
37
+ },
38
+ });
39
+ setData(next);
40
+ setError(null);
41
+ return next;
42
+ } catch (err) {
43
+ setError(err);
44
+ throw err;
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ },
49
+ [enabled, fetcher, maxAgeMs, normalizedKey, staleWhileRevalidate],
50
+ );
51
+
52
+ useEffect(() => {
53
+ if (!enabled) return;
54
+ refresh().catch(() => {});
55
+ }, [enabled, refresh]);
56
+
57
+ return {
58
+ data,
59
+ error,
60
+ loading,
61
+ refresh,
62
+ };
63
+ };
@@ -1,7 +1,19 @@
1
- import { useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact/hooks';
1
+ import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
2
+ import { getCached, setCached } from "../lib/api-cache.js";
2
3
 
3
- export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
4
- const [data, setData] = useState(null);
4
+ export const usePolling = (
5
+ fetcher,
6
+ interval,
7
+ {
8
+ enabled = true,
9
+ pauseWhenHidden = true,
10
+ cacheKey = "",
11
+ } = {},
12
+ ) => {
13
+ const normalizedCacheKey = String(cacheKey || "");
14
+ const [data, setData] = useState(() =>
15
+ normalizedCacheKey ? getCached(normalizedCacheKey) : null,
16
+ );
5
17
  const [error, setError] = useState(null);
6
18
  const fetcherRef = useRef(fetcher);
7
19
  fetcherRef.current = fetcher;
@@ -9,6 +21,9 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
9
21
  const refresh = useCallback(async () => {
10
22
  try {
11
23
  const result = await fetcherRef.current();
24
+ if (normalizedCacheKey) {
25
+ setCached(normalizedCacheKey, result);
26
+ }
12
27
  setData(result);
13
28
  setError(null);
14
29
  return result;
@@ -16,14 +31,37 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
16
31
  setError(err);
17
32
  return null;
18
33
  }
19
- }, []);
34
+ }, [normalizedCacheKey]);
35
+
36
+ useEffect(() => {
37
+ if (!normalizedCacheKey) return;
38
+ const cached = getCached(normalizedCacheKey);
39
+ if (cached !== null) {
40
+ setData(cached);
41
+ }
42
+ }, [normalizedCacheKey]);
20
43
 
21
44
  useEffect(() => {
22
45
  if (!enabled) return;
46
+ if (pauseWhenHidden && typeof document !== "undefined" && document.hidden) {
47
+ return undefined;
48
+ }
23
49
  refresh();
24
- const id = setInterval(refresh, interval);
25
- return () => clearInterval(id);
26
- }, [enabled, interval, refresh]);
50
+ const intervalId = setInterval(refresh, interval);
51
+ return () => clearInterval(intervalId);
52
+ }, [enabled, interval, pauseWhenHidden, refresh]);
53
+
54
+ useEffect(() => {
55
+ if (!enabled || !pauseWhenHidden || typeof document === "undefined") return;
56
+ const handleVisibilityChange = () => {
57
+ if (!document.hidden) {
58
+ refresh();
59
+ }
60
+ };
61
+ document.addEventListener("visibilitychange", handleVisibilityChange);
62
+ return () =>
63
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
64
+ }, [enabled, pauseWhenHidden, refresh]);
27
65
 
28
66
  return { data, error, refresh };
29
67
  };
@@ -0,0 +1,88 @@
1
+ const kApiCache = new Map();
2
+ const kInFlightByKey = new Map();
3
+
4
+ const nowMs = () => Date.now();
5
+
6
+ const isFresh = (entry, maxAgeMs) => {
7
+ if (!entry) return false;
8
+ return nowMs() - Number(entry.fetchedAt || 0) < Number(maxAgeMs || 0);
9
+ };
10
+
11
+ export const getCached = (key = "") => {
12
+ const normalizedKey = String(key || "");
13
+ if (!normalizedKey) return null;
14
+ return kApiCache.get(normalizedKey)?.data ?? null;
15
+ };
16
+
17
+ export const setCached = (key = "", data = null) => {
18
+ const normalizedKey = String(key || "");
19
+ if (!normalizedKey) return data;
20
+ kApiCache.set(normalizedKey, {
21
+ data,
22
+ fetchedAt: nowMs(),
23
+ });
24
+ return data;
25
+ };
26
+
27
+ export const invalidateCache = (key = "") => {
28
+ const normalizedKey = String(key || "");
29
+ if (!normalizedKey) return;
30
+ kApiCache.delete(normalizedKey);
31
+ kInFlightByKey.delete(normalizedKey);
32
+ };
33
+
34
+ export const cachedFetch = async (
35
+ key,
36
+ fetcher,
37
+ {
38
+ maxAgeMs = 15000,
39
+ force = false,
40
+ staleWhileRevalidate = true,
41
+ onRevalidate = null,
42
+ } = {},
43
+ ) => {
44
+ const normalizedKey = String(key || "");
45
+ if (!normalizedKey || typeof fetcher !== "function") {
46
+ return fetcher();
47
+ }
48
+
49
+ const entry = kApiCache.get(normalizedKey);
50
+ if (!force && isFresh(entry, maxAgeMs)) {
51
+ return entry.data;
52
+ }
53
+
54
+ if (!force && staleWhileRevalidate && entry) {
55
+ if (!kInFlightByKey.has(normalizedKey)) {
56
+ const backgroundPromise = Promise.resolve()
57
+ .then(() => fetcher())
58
+ .then((result) => {
59
+ setCached(normalizedKey, result);
60
+ if (typeof onRevalidate === "function") {
61
+ onRevalidate(result);
62
+ }
63
+ return result;
64
+ })
65
+ .finally(() => {
66
+ kInFlightByKey.delete(normalizedKey);
67
+ });
68
+ kInFlightByKey.set(normalizedKey, backgroundPromise);
69
+ }
70
+ return entry.data;
71
+ }
72
+
73
+ if (kInFlightByKey.has(normalizedKey)) {
74
+ return kInFlightByKey.get(normalizedKey);
75
+ }
76
+
77
+ const requestPromise = Promise.resolve()
78
+ .then(() => fetcher())
79
+ .then((result) => {
80
+ setCached(normalizedKey, result);
81
+ return result;
82
+ })
83
+ .finally(() => {
84
+ kInFlightByKey.delete(normalizedKey);
85
+ });
86
+ kInFlightByKey.set(normalizedKey, requestPromise);
87
+ return requestPromise;
88
+ };
@@ -31,6 +31,35 @@ export const authFetch = async (url, opts = {}) => {
31
31
  return res;
32
32
  };
33
33
 
34
+ export const subscribeStatusEvents = ({
35
+ onMessage = () => {},
36
+ onOpen = () => {},
37
+ onError = () => {},
38
+ } = {}) => {
39
+ if (typeof window?.EventSource !== "function") {
40
+ throw new Error("Server events are not supported in this browser");
41
+ }
42
+ const source = new window.EventSource("/api/events/status", {
43
+ withCredentials: true,
44
+ });
45
+ const handleStatus = (event) => {
46
+ let payload = {};
47
+ try {
48
+ payload = event?.data ? JSON.parse(event.data) : {};
49
+ } catch {}
50
+ onMessage(payload || {});
51
+ };
52
+ source.addEventListener("status", handleStatus);
53
+ source.onopen = () => onOpen();
54
+ source.onerror = (event) => onError(event);
55
+ return () => {
56
+ source.removeEventListener("status", handleStatus);
57
+ source.onopen = null;
58
+ source.onerror = null;
59
+ source.close();
60
+ };
61
+ };
62
+
34
63
  export async function fetchStatus() {
35
64
  const res = await authFetch("/api/status");
36
65
  return res.json();
@@ -132,6 +132,8 @@ const registerServerRoutes = ({
132
132
  restartRequiredState,
133
133
  topicRegistry,
134
134
  authProfiles,
135
+ watchdog,
136
+ doctorService,
135
137
  });
136
138
  registerBrowseRoutes({
137
139
  app,
@@ -25,6 +25,8 @@ const registerSystemRoutes = ({
25
25
  restartRequiredState,
26
26
  topicRegistry,
27
27
  authProfiles,
28
+ watchdog,
29
+ doctorService,
28
30
  }) => {
29
31
  let envRestartPending = false;
30
32
  const kManagedChannelTokenPattern =
@@ -465,12 +467,12 @@ const registerSystemRoutes = ({
465
467
  res.json({ ok: true, changed, restartRequired });
466
468
  });
467
469
 
468
- app.get("/api/status", async (req, res) => {
470
+ const buildStatusPayload = async () => {
469
471
  const configExists = fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`);
470
472
  const running = await isGatewayRunning();
471
473
  const repo = process.env.GITHUB_WORKSPACE_REPO || "";
472
474
  const openclawVersion = openclawVersionService.readOpenclawVersion();
473
- res.json({
475
+ return {
474
476
  gateway: running
475
477
  ? "running"
476
478
  : configExists
@@ -481,6 +483,52 @@ const registerSystemRoutes = ({
481
483
  repo,
482
484
  openclawVersion,
483
485
  syncCron: getSystemCronStatus(),
486
+ };
487
+ };
488
+
489
+ app.get("/api/status", async (req, res) => {
490
+ const payload = await buildStatusPayload();
491
+ res.json(payload);
492
+ });
493
+
494
+ app.get("/api/events/status", async (req, res) => {
495
+ res.setHeader("Content-Type", "text/event-stream");
496
+ res.setHeader("Cache-Control", "no-cache, no-transform");
497
+ res.setHeader("Connection", "keep-alive");
498
+ res.setHeader("X-Accel-Buffering", "no");
499
+ res.flushHeaders?.();
500
+
501
+ const writeStatusEvent = async () => {
502
+ try {
503
+ const status = await buildStatusPayload();
504
+ const watchdogStatus =
505
+ typeof watchdog?.getStatus === "function" ? watchdog.getStatus() : null;
506
+ const doctorStatus =
507
+ typeof doctorService?.buildStatus === "function"
508
+ ? doctorService.buildStatus()
509
+ : null;
510
+ res.write("event: status\n");
511
+ res.write(
512
+ `data: ${JSON.stringify({
513
+ status,
514
+ watchdogStatus,
515
+ doctorStatus,
516
+ timestamp: new Date().toISOString(),
517
+ })}\n\n`,
518
+ );
519
+ } catch {}
520
+ };
521
+
522
+ await writeStatusEvent();
523
+ const statusIntervalId = setInterval(writeStatusEvent, 2000);
524
+ const keepAliveIntervalId = setInterval(() => {
525
+ res.write(": keepalive\n\n");
526
+ }, 15000);
527
+
528
+ req.on("close", () => {
529
+ clearInterval(statusIntervalId);
530
+ clearInterval(keepAliveIntervalId);
531
+ res.end();
484
532
  });
485
533
  });
486
534
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.7.2-beta.7",
3
+ "version": "0.8.1-beta.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },