@chrysb/alphaclaw 0.4.6-beta.4 → 0.4.6-beta.6

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 (52) hide show
  1. package/lib/public/js/app.js +158 -1073
  2. package/lib/public/js/components/envars.js +146 -29
  3. package/lib/public/js/components/features.js +1 -1
  4. package/lib/public/js/components/general/index.js +155 -0
  5. package/lib/public/js/components/icons.js +52 -0
  6. package/lib/public/js/components/info-tooltip.js +4 -7
  7. package/lib/public/js/components/models-tab/index.js +286 -0
  8. package/lib/public/js/components/models-tab/provider-auth-card.js +369 -0
  9. package/lib/public/js/components/models-tab/use-models.js +262 -0
  10. package/lib/public/js/components/models.js +1 -1
  11. package/lib/public/js/components/providers.js +1 -1
  12. package/lib/public/js/components/routes/browse-route.js +35 -0
  13. package/lib/public/js/components/routes/doctor-route.js +21 -0
  14. package/lib/public/js/components/routes/envars-route.js +11 -0
  15. package/lib/public/js/components/routes/general-route.js +45 -0
  16. package/lib/public/js/components/routes/index.js +11 -0
  17. package/lib/public/js/components/routes/models-route.js +11 -0
  18. package/lib/public/js/components/routes/providers-route.js +11 -0
  19. package/lib/public/js/components/routes/route-redirect.js +10 -0
  20. package/lib/public/js/components/routes/telegram-route.js +11 -0
  21. package/lib/public/js/components/routes/usage-route.js +15 -0
  22. package/lib/public/js/components/routes/watchdog-route.js +32 -0
  23. package/lib/public/js/components/routes/webhooks-route.js +43 -0
  24. package/lib/public/js/components/sidebar.js +2 -3
  25. package/lib/public/js/components/tooltip.js +106 -0
  26. package/lib/public/js/components/usage-tab/constants.js +1 -1
  27. package/lib/public/js/components/usage-tab/overview-section.js +124 -50
  28. package/lib/public/js/components/usage-tab/use-usage-tab.js +42 -11
  29. package/lib/public/js/components/welcome.js +1 -1
  30. package/lib/public/js/hooks/use-app-shell-controller.js +230 -0
  31. package/lib/public/js/hooks/use-app-shell-ui.js +112 -0
  32. package/lib/public/js/hooks/use-browse-navigation.js +193 -0
  33. package/lib/public/js/hooks/use-hash-location.js +32 -0
  34. package/lib/public/js/lib/api.js +35 -0
  35. package/lib/public/js/lib/app-navigation.js +39 -0
  36. package/lib/public/js/lib/browse-restart-policy.js +28 -0
  37. package/lib/public/js/lib/browse-route.js +57 -0
  38. package/lib/public/js/lib/format.js +12 -0
  39. package/lib/public/js/lib/model-config.js +1 -0
  40. package/lib/server/auth-profiles.js +291 -53
  41. package/lib/server/constants.js +24 -8
  42. package/lib/server/doctor/service.js +0 -3
  43. package/lib/server/gateway.js +50 -31
  44. package/lib/server/onboarding/index.js +2 -0
  45. package/lib/server/onboarding/validation.js +2 -2
  46. package/lib/server/routes/models.js +214 -2
  47. package/lib/server/routes/onboarding.js +2 -0
  48. package/lib/server/routes/system.js +42 -1
  49. package/lib/server/watchdog.js +14 -1
  50. package/lib/server.js +6 -0
  51. package/lib/setup/env.template +1 -0
  52. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  export const kColorPalette = [
2
2
  "#7dd3fc",
3
3
  "#22d3ee",
4
- "#34d399",
5
4
  "#fbbf24",
5
+ "#34d399",
6
6
  "#fb7185",
7
7
  "#a78bfa",
8
8
  "#f472b6",
@@ -1,7 +1,11 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
- import { formatInteger, formatUsd } from "../../lib/format.js";
4
+ import {
5
+ formatCompactNumber,
6
+ formatInteger,
7
+ formatUsd,
8
+ } from "../../lib/format.js";
5
9
  import { SegmentedControl } from "../segmented-control.js";
6
10
  import { kRangeOptions, kUsageSourceOrder } from "./constants.js";
7
11
  import { renderSourceLabel } from "./formatters.js";
@@ -14,6 +18,34 @@ const formatCountLabel = (value, singular, plural) => {
14
18
  return `${formatInteger(count)} ${label}`;
15
19
  };
16
20
 
21
+ const formatPercent = (ratio) => `${(Number(ratio || 0) * 100).toFixed(1)}%`;
22
+
23
+ const getCacheHitRateValueClass = (ratio) => {
24
+ const percent = Number(ratio || 0) * 100;
25
+ if (percent <= 0) return "text-gray-300";
26
+ if (percent >= 70) return "text-green-400";
27
+ if (percent >= 40) return "text-amber-300";
28
+ return "text-red-400";
29
+ };
30
+
31
+ const getOverviewMetrics = (summary) => {
32
+ const totals = summary?.totals || {};
33
+ const cacheReadTokens = Number(totals.cacheReadTokens || 0);
34
+ const inputTokens = Number(totals.inputTokens || 0);
35
+ const promptTokens = inputTokens + cacheReadTokens;
36
+ const turnCount = Number(totals.turnCount || 0);
37
+ const totalTokens = Number(totals.totalTokens || 0);
38
+ const totalCost = Number(totals.totalCost || 0);
39
+ return {
40
+ cacheHitRate: promptTokens > 0 ? cacheReadTokens / promptTokens : 0,
41
+ cacheReadTokens,
42
+ promptTokens,
43
+ avgTokensPerTurn: turnCount > 0 ? totalTokens / turnCount : 0,
44
+ avgCostPerTurn: turnCount > 0 ? totalCost / turnCount : 0,
45
+ turnCount,
46
+ };
47
+ };
48
+
17
49
  const SummaryCard = ({ title, tokens, cost }) => html`
18
50
  <div class="bg-surface border border-border rounded-xl p-4">
19
51
  <h3 class="card-label text-xs">${title}</h3>
@@ -25,6 +57,25 @@ const SummaryCard = ({ title, tokens, cost }) => html`
25
57
  </div>
26
58
  `;
27
59
 
60
+ const MetricCard = ({
61
+ title,
62
+ value,
63
+ detail = "",
64
+ valueClass = "",
65
+ valueSuffix = "",
66
+ }) => html`
67
+ <div class="bg-surface border border-border rounded-xl p-4">
68
+ <h3 class="card-label text-xs">${title}</h3>
69
+ <div class=${`text-lg font-semibold mt-1 ${valueClass}`.trim()}>
70
+ ${value}
71
+ ${valueSuffix
72
+ ? html`<span class="text-xs text-[var(--text-muted)] ml-1">${valueSuffix}</span>`
73
+ : null}
74
+ </div>
75
+ <div class="text-xs text-[var(--text-muted)] mt-1">${detail}</div>
76
+ </div>
77
+ `;
78
+
28
79
  const AgentCostDistribution = ({ summary }) => {
29
80
  const agents = Array.isArray(summary?.costByAgent?.agents)
30
81
  ? summary.costByAgent.agents
@@ -167,55 +218,78 @@ export const OverviewSection = ({
167
218
  overviewCanvasRef,
168
219
  onDaysChange = () => {},
169
220
  onMetricChange = () => {},
170
- }) => html`
171
- <div class="space-y-4">
172
- <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
173
- <${SummaryCard}
174
- title="Today"
175
- tokens=${periodSummary.today.tokens}
176
- cost=${periodSummary.today.cost}
177
- />
178
- <${SummaryCard}
179
- title="Last 7 days"
180
- tokens=${periodSummary.week.tokens}
181
- cost=${periodSummary.week.cost}
182
- />
183
- <${SummaryCard}
184
- title="Last 30 days"
185
- tokens=${periodSummary.month.tokens}
186
- cost=${periodSummary.month.cost}
187
- />
188
- </div>
189
- <div class="bg-surface border border-border rounded-xl p-4">
190
- <div
191
- class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
192
- >
193
- <h2 class="card-label text-xs">
194
- Daily ${metric === "tokens" ? "tokens" : "cost"} by model
195
- </h2>
196
- <div class="flex items-center gap-2">
197
- <${SegmentedControl}
198
- options=${kRangeOptions.map((option) => ({
199
- label: option.label,
200
- value: option.value,
201
- }))}
202
- value=${days}
203
- onChange=${onDaysChange}
204
- />
205
- <${SegmentedControl}
206
- options=${[
207
- { label: "tokens", value: "tokens" },
208
- { label: "cost", value: "cost" },
209
- ]}
210
- value=${metric}
211
- onChange=${onMetricChange}
212
- />
213
- </div>
221
+ }) => {
222
+ const overviewMetrics = getOverviewMetrics(summary);
223
+
224
+ return html`
225
+ <div class="space-y-4">
226
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
227
+ <${SummaryCard}
228
+ title="Today"
229
+ tokens=${periodSummary.today.tokens}
230
+ cost=${periodSummary.today.cost}
231
+ />
232
+ <${SummaryCard}
233
+ title="Last 7 days"
234
+ tokens=${periodSummary.week.tokens}
235
+ cost=${periodSummary.week.cost}
236
+ />
237
+ <${SummaryCard}
238
+ title="Last 30 days"
239
+ tokens=${periodSummary.month.tokens}
240
+ cost=${periodSummary.month.cost}
241
+ />
214
242
  </div>
215
- <div style=${{ height: "280px" }}>
216
- <canvas ref=${overviewCanvasRef}></canvas>
243
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
244
+ <${MetricCard}
245
+ title="Cache hit rate"
246
+ value=${formatPercent(overviewMetrics.cacheHitRate)}
247
+ valueClass=${getCacheHitRateValueClass(overviewMetrics.cacheHitRate)}
248
+ detail=${`${formatCompactNumber(overviewMetrics.cacheReadTokens)} cached · ${formatCompactNumber(overviewMetrics.promptTokens)} prompt`}
249
+ />
250
+ <${MetricCard}
251
+ title="Avg tokens per turn"
252
+ value=${formatCompactNumber(overviewMetrics.avgTokensPerTurn)}
253
+ valueSuffix="tokens"
254
+ detail=${`${formatCountLabel(overviewMetrics.turnCount, "turn", "turns")} last ${days} days`}
255
+ />
256
+ <${MetricCard}
257
+ title="Avg cost per turn"
258
+ value=${formatUsd(overviewMetrics.avgCostPerTurn)}
259
+ detail=${`${formatCountLabel(overviewMetrics.turnCount, "turn", "turns")} last ${days} days`}
260
+ />
217
261
  </div>
262
+ <div class="bg-surface border border-border rounded-xl p-4">
263
+ <div
264
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
265
+ >
266
+ <h2 class="card-label text-xs">
267
+ Daily ${metric === "tokens" ? "tokens" : "cost"} by model
268
+ </h2>
269
+ <div class="flex items-center gap-2">
270
+ <${SegmentedControl}
271
+ options=${kRangeOptions.map((option) => ({
272
+ label: option.label,
273
+ value: option.value,
274
+ }))}
275
+ value=${days}
276
+ onChange=${onDaysChange}
277
+ />
278
+ <${SegmentedControl}
279
+ options=${[
280
+ { label: "tokens", value: "tokens" },
281
+ { label: "cost", value: "cost" },
282
+ ]}
283
+ value=${metric}
284
+ onChange=${onMetricChange}
285
+ />
286
+ </div>
287
+ </div>
288
+ <div style=${{ height: "280px" }}>
289
+ <canvas ref=${overviewCanvasRef}></canvas>
290
+ </div>
291
+ </div>
292
+ <${AgentCostDistribution} summary=${summary} />
218
293
  </div>
219
- <${AgentCostDistribution} summary=${summary} />
220
- </div>
221
- `;
294
+ `;
295
+ };
@@ -1,4 +1,10 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
2
8
  import {
3
9
  fetchUsageSessionDetail,
4
10
  fetchUsageSessions,
@@ -17,12 +23,17 @@ import { toChartColor, toLocalDayKey } from "./formatters.js";
17
23
  export const useUsageTab = ({ sessionId = "" }) => {
18
24
  const [days, setDays] = useState(() => {
19
25
  const settings = readUiSettings();
20
- const parsedDays = Number.parseInt(String(settings[kUsageDaysUiSettingKey] ?? ""), 10);
26
+ const parsedDays = Number.parseInt(
27
+ String(settings[kUsageDaysUiSettingKey] ?? ""),
28
+ 10,
29
+ );
21
30
  return [7, 30, 90].includes(parsedDays) ? parsedDays : kDefaultUsageDays;
22
31
  });
23
32
  const [metric, setMetric] = useState(() => {
24
33
  const settings = readUiSettings();
25
- return settings[kUsageMetricUiSettingKey] === "cost" ? "cost" : kDefaultUsageMetric;
34
+ return settings[kUsageMetricUiSettingKey] === "cost"
35
+ ? "cost"
36
+ : kDefaultUsageMetric;
26
37
  });
27
38
  const [summary, setSummary] = useState(null);
28
39
  const [sessions, setSessions] = useState([]);
@@ -104,9 +115,14 @@ export const useUsageTab = ({ sessionId = "" }) => {
104
115
  const safeSessionId = String(sessionId || "").trim();
105
116
  if (!safeSessionId) return;
106
117
  setExpandedSessionIds((currentValue) =>
107
- currentValue.includes(safeSessionId) ? currentValue : [...currentValue, safeSessionId],
118
+ currentValue.includes(safeSessionId)
119
+ ? currentValue
120
+ : [...currentValue, safeSessionId],
108
121
  );
109
- if (!sessionDetailById[safeSessionId] && !loadingDetailById[safeSessionId]) {
122
+ if (
123
+ !sessionDetailById[safeSessionId] &&
124
+ !loadingDetailById[safeSessionId]
125
+ ) {
110
126
  loadSessionDetail(safeSessionId);
111
127
  }
112
128
  }, [sessionId, sessionDetailById, loadingDetailById, loadSessionDetail]);
@@ -115,8 +131,12 @@ export const useUsageTab = ({ sessionId = "" }) => {
115
131
  const rows = Array.isArray(summary?.daily) ? summary.daily : [];
116
132
  const now = new Date();
117
133
  const dayKey = toLocalDayKey(now);
118
- const weekStart = toLocalDayKey(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000));
119
- const monthStart = toLocalDayKey(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000));
134
+ const weekStart = toLocalDayKey(
135
+ new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
136
+ );
137
+ const monthStart = toLocalDayKey(
138
+ new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
139
+ );
120
140
  const zero = { tokens: 0, cost: 0 };
121
141
  return rows.reduce(
122
142
  (acc, row) => {
@@ -156,9 +176,13 @@ export const useUsageTab = ({ sessionId = "" }) => {
156
176
  const datasets = Array.from(allModels).map((model) => ({
157
177
  label: model,
158
178
  data: rows.map((row) => {
159
- const found = (row.models || []).find((m) => String(m.model || "") === model);
179
+ const found = (row.models || []).find(
180
+ (m) => String(m.model || "") === model,
181
+ );
160
182
  if (!found) return 0;
161
- return metric === "cost" ? Number(found.totalCost || 0) : Number(found.totalTokens || 0);
183
+ return metric === "cost"
184
+ ? Number(found.totalCost || 0)
185
+ : Number(found.totalTokens || 0);
162
186
  }),
163
187
  backgroundColor: toChartColor(model),
164
188
  }));
@@ -186,13 +210,20 @@ export const useUsageTab = ({ sessionId = "" }) => {
186
210
  stacked: true,
187
211
  ticks: {
188
212
  color: "rgba(156,163,175,1)",
189
- callback: (v) => (metric === "cost" ? `$${Number(v).toFixed(2)}` : formatInteger(v)),
213
+ callback: (v) =>
214
+ metric === "cost"
215
+ ? `$${Number(v).toFixed(2)}`
216
+ : formatInteger(v),
190
217
  },
191
218
  },
192
219
  },
193
220
  plugins: {
194
221
  legend: {
195
- labels: { color: "rgba(209,213,219,1)", boxWidth: 10, boxHeight: 10 },
222
+ labels: {
223
+ color: "rgba(209,213,219,1)",
224
+ boxWidth: 10,
225
+ boxHeight: 10,
226
+ },
196
227
  },
197
228
  tooltip: {
198
229
  callbacks: {
@@ -101,7 +101,7 @@ export const Welcome = ({ onComplete }) => {
101
101
  : selectedProvider === "google"
102
102
  ? !!vals.GEMINI_API_KEY
103
103
  : selectedProvider === "openai-codex"
104
- ? !!(codexStatus.connected || vals.OPENAI_API_KEY)
104
+ ? !!codexStatus.connected
105
105
  : false;
106
106
 
107
107
  const allValid = kWelcomeGroups.every((g) => g.validate(vals, { hasAi }));
@@ -0,0 +1,230 @@
1
+ import { useState, useEffect, useCallback } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchStatus,
4
+ fetchOnboardStatus,
5
+ fetchAuthStatus,
6
+ fetchAlphaclawVersion,
7
+ updateAlphaclaw,
8
+ fetchRestartStatus,
9
+ restartGateway,
10
+ fetchWatchdogStatus,
11
+ fetchDoctorStatus,
12
+ updateOpenclaw,
13
+ } from "../lib/api.js";
14
+ import { shouldRequireRestartForBrowsePath } from "../lib/browse-restart-policy.js";
15
+ import { usePolling } from "./usePolling.js";
16
+ import { showToast } from "../components/toast.js";
17
+
18
+ export const useAppShellController = ({ location = "" } = {}) => {
19
+ const [onboarded, setOnboarded] = useState(null);
20
+ const [authEnabled, setAuthEnabled] = useState(false);
21
+ const [acVersion, setAcVersion] = useState(null);
22
+ const [acLatest, setAcLatest] = useState(null);
23
+ const [acHasUpdate, setAcHasUpdate] = useState(false);
24
+ const [acUpdating, setAcUpdating] = useState(false);
25
+ const [restartRequired, setRestartRequired] = useState(false);
26
+ const [browseRestartRequired, setBrowseRestartRequired] = useState(false);
27
+ const [restartingGateway, setRestartingGateway] = useState(false);
28
+ const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);
29
+ const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);
30
+ const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
31
+
32
+ const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
33
+ enabled: onboarded === true,
34
+ });
35
+ const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
36
+ enabled: onboarded === true,
37
+ });
38
+ const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
39
+ enabled: onboarded === true,
40
+ });
41
+ const sharedStatus = sharedStatusPoll.data || null;
42
+ const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null;
43
+ const sharedDoctorStatus = sharedDoctorPoll.data?.status || null;
44
+ const isAnyRestartRequired = restartRequired || browseRestartRequired;
45
+
46
+ const refreshSharedStatuses = useCallback(() => {
47
+ sharedStatusPoll.refresh();
48
+ sharedWatchdogPoll.refresh();
49
+ sharedDoctorPoll.refresh();
50
+ }, [sharedDoctorPoll.refresh, sharedStatusPoll.refresh, sharedWatchdogPoll.refresh]);
51
+
52
+ useEffect(() => {
53
+ fetchOnboardStatus()
54
+ .then((data) => setOnboarded(data.onboarded))
55
+ .catch(() => setOnboarded(false));
56
+ fetchAuthStatus()
57
+ .then((data) => setAuthEnabled(!!data.authEnabled))
58
+ .catch(() => {});
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!onboarded) return;
63
+ let active = true;
64
+ const check = async (refresh = false) => {
65
+ try {
66
+ const data = await fetchAlphaclawVersion(refresh);
67
+ if (!active) return;
68
+ setAcVersion(data.currentVersion || null);
69
+ setAcLatest(data.latestVersion || null);
70
+ setAcHasUpdate(!!data.hasUpdate);
71
+ } catch {}
72
+ };
73
+ check(true);
74
+ const id = setInterval(() => check(false), 5 * 60 * 1000);
75
+ return () => {
76
+ active = false;
77
+ clearInterval(id);
78
+ };
79
+ }, [onboarded]);
80
+
81
+ const refreshRestartStatus = useCallback(async () => {
82
+ if (!onboarded) return;
83
+ try {
84
+ const data = await fetchRestartStatus();
85
+ setRestartRequired(!!data.restartRequired);
86
+ setRestartingGateway(!!data.restartInProgress);
87
+ } catch {}
88
+ }, [onboarded]);
89
+
90
+ useEffect(() => {
91
+ if (!onboarded) return;
92
+ refreshRestartStatus();
93
+ }, [onboarded, refreshRestartStatus]);
94
+
95
+ useEffect(() => {
96
+ if (onboarded !== true) return;
97
+ const inStatusView =
98
+ location.startsWith("/general") || location.startsWith("/watchdog");
99
+ const gatewayStatus = sharedStatus?.gateway ?? null;
100
+ const watchdogHealth = String(sharedWatchdogStatus?.health || "").toLowerCase();
101
+ const watchdogLifecycle = String(sharedWatchdogStatus?.lifecycle || "").toLowerCase();
102
+ const shouldFastPollWatchdog =
103
+ watchdogHealth === "unknown" ||
104
+ watchdogLifecycle === "restarting" ||
105
+ watchdogLifecycle === "stopped" ||
106
+ !!sharedWatchdogStatus?.operationInProgress;
107
+ const shouldFastPollGateway = !gatewayStatus || gatewayStatus !== "running";
108
+ const nextCadenceMs =
109
+ inStatusView && (shouldFastPollWatchdog || shouldFastPollGateway) ? 2000 : 15000;
110
+ setStatusPollCadenceMs((currentCadenceMs) =>
111
+ currentCadenceMs === nextCadenceMs ? currentCadenceMs : nextCadenceMs,
112
+ );
113
+ }, [
114
+ location,
115
+ onboarded,
116
+ sharedStatus?.gateway,
117
+ sharedWatchdogStatus?.health,
118
+ sharedWatchdogStatus?.lifecycle,
119
+ sharedWatchdogStatus?.operationInProgress,
120
+ ]);
121
+
122
+ useEffect(() => {
123
+ if (!onboarded || (!restartRequired && !restartingGateway)) return;
124
+ const id = setInterval(refreshRestartStatus, 2000);
125
+ return () => clearInterval(id);
126
+ }, [onboarded, restartRequired, restartingGateway, refreshRestartStatus]);
127
+
128
+ useEffect(() => {
129
+ const handleBrowseFileSaved = (event) => {
130
+ const savedPath = String(event?.detail?.path || "");
131
+ if (!shouldRequireRestartForBrowsePath(savedPath)) return;
132
+ setBrowseRestartRequired(true);
133
+ };
134
+ window.addEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved);
135
+ return () => {
136
+ window.removeEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved);
137
+ };
138
+ }, []);
139
+
140
+ const handleGatewayRestart = useCallback(async () => {
141
+ if (restartingGateway) return;
142
+ setRestartingGateway(true);
143
+ try {
144
+ const data = await restartGateway();
145
+ if (!data?.ok) throw new Error(data?.error || "Gateway restart failed");
146
+ setRestartRequired(!!data.restartRequired);
147
+ setBrowseRestartRequired(false);
148
+ setGatewayRestartSignal(Date.now());
149
+ refreshSharedStatuses();
150
+ showToast("Gateway restarted", "success");
151
+ setTimeout(refreshRestartStatus, 800);
152
+ } catch (err) {
153
+ showToast(err.message || "Restart failed", "error");
154
+ setTimeout(refreshRestartStatus, 800);
155
+ } finally {
156
+ setRestartingGateway(false);
157
+ }
158
+ }, [refreshRestartStatus, refreshSharedStatuses, restartingGateway]);
159
+
160
+ const handleOpenclawUpdate = useCallback(async () => {
161
+ if (openclawUpdateInProgress) {
162
+ return { ok: false, error: "OpenClaw update already in progress" };
163
+ }
164
+ setOpenclawUpdateInProgress(true);
165
+ try {
166
+ const data = await updateOpenclaw();
167
+ return data;
168
+ } finally {
169
+ setOpenclawUpdateInProgress(false);
170
+ refreshSharedStatuses();
171
+ setTimeout(refreshSharedStatuses, 1200);
172
+ setTimeout(refreshSharedStatuses, 3500);
173
+ setTimeout(refreshRestartStatus, 1200);
174
+ }
175
+ }, [openclawUpdateInProgress, refreshRestartStatus, refreshSharedStatuses]);
176
+
177
+ const handleOpenclawVersionActionComplete = useCallback(
178
+ ({ type }) => {
179
+ if (type !== "update") return;
180
+ refreshSharedStatuses();
181
+ setTimeout(refreshSharedStatuses, 1200);
182
+ },
183
+ [refreshSharedStatuses],
184
+ );
185
+
186
+ const handleAcUpdate = useCallback(async () => {
187
+ if (acUpdating) return;
188
+ setAcUpdating(true);
189
+ try {
190
+ const data = await updateAlphaclaw();
191
+ if (data.ok) {
192
+ showToast("AlphaClaw updated — restarting...", "success");
193
+ setTimeout(() => window.location.reload(), 5000);
194
+ } else {
195
+ showToast(data.error || "AlphaClaw update failed", "error");
196
+ setAcUpdating(false);
197
+ }
198
+ } catch (err) {
199
+ showToast(err.message || "Could not update AlphaClaw", "error");
200
+ setAcUpdating(false);
201
+ }
202
+ }, [acUpdating]);
203
+
204
+ return {
205
+ state: {
206
+ acHasUpdate,
207
+ acLatest,
208
+ acUpdating,
209
+ acVersion,
210
+ authEnabled,
211
+ gatewayRestartSignal,
212
+ isAnyRestartRequired,
213
+ onboarded,
214
+ openclawUpdateInProgress,
215
+ restartingGateway,
216
+ sharedDoctorStatus,
217
+ sharedStatus,
218
+ sharedWatchdogStatus,
219
+ },
220
+ actions: {
221
+ handleAcUpdate,
222
+ handleGatewayRestart,
223
+ handleOnboardingComplete: () => setOnboarded(true),
224
+ handleOpenclawUpdate,
225
+ handleOpenclawVersionActionComplete,
226
+ refreshSharedStatuses,
227
+ setRestartRequired,
228
+ },
229
+ };
230
+ };
@@ -0,0 +1,112 @@
1
+ import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
3
+
4
+ const kDefaultSidebarWidthPx = 220;
5
+ const kSidebarMinWidthPx = 180;
6
+ const kSidebarMaxWidthPx = 460;
7
+
8
+ const clampSidebarWidth = (value) =>
9
+ Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value));
10
+
11
+ export const useAppShellUi = () => {
12
+ const appShellRef = useRef(null);
13
+ const menuRef = useRef(null);
14
+ const [menuOpen, setMenuOpen] = useState(false);
15
+ const [sidebarWidthPx, setSidebarWidthPx] = useState(() => {
16
+ const settings = readUiSettings();
17
+ if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx;
18
+ return clampSidebarWidth(settings.sidebarWidthPx);
19
+ });
20
+ const [isResizingSidebar, setIsResizingSidebar] = useState(false);
21
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
22
+ const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false);
23
+
24
+ const closeMenu = useCallback((event) => {
25
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
26
+ setMenuOpen(false);
27
+ }
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ if (menuOpen) {
32
+ document.addEventListener("click", closeMenu, true);
33
+ return () => document.removeEventListener("click", closeMenu, true);
34
+ }
35
+ }, [closeMenu, menuOpen]);
36
+
37
+ useEffect(() => {
38
+ if (!mobileSidebarOpen) return;
39
+ const previousOverflow = document.body.style.overflow;
40
+ document.body.style.overflow = "hidden";
41
+ return () => {
42
+ document.body.style.overflow = previousOverflow;
43
+ };
44
+ }, [mobileSidebarOpen]);
45
+
46
+ useEffect(() => {
47
+ const settings = readUiSettings();
48
+ settings.sidebarWidthPx = sidebarWidthPx;
49
+ writeUiSettings(settings);
50
+ }, [sidebarWidthPx]);
51
+
52
+ const resizeSidebarWithClientX = useCallback((clientX) => {
53
+ const shellElement = appShellRef.current;
54
+ if (!shellElement) return;
55
+ const shellBounds = shellElement.getBoundingClientRect();
56
+ const nextWidth = clampSidebarWidth(Math.round(clientX - shellBounds.left));
57
+ setSidebarWidthPx(nextWidth);
58
+ }, []);
59
+
60
+ const onSidebarResizerPointerDown = useCallback((event) => {
61
+ event.preventDefault();
62
+ setIsResizingSidebar(true);
63
+ resizeSidebarWithClientX(event.clientX);
64
+ }, [resizeSidebarWithClientX]);
65
+
66
+ useEffect(() => {
67
+ if (!isResizingSidebar) return () => {};
68
+ const onPointerMove = (event) => resizeSidebarWithClientX(event.clientX);
69
+ const onPointerUp = () => setIsResizingSidebar(false);
70
+ window.addEventListener("pointermove", onPointerMove);
71
+ window.addEventListener("pointerup", onPointerUp);
72
+ const previousUserSelect = document.body.style.userSelect;
73
+ const previousCursor = document.body.style.cursor;
74
+ document.body.style.userSelect = "none";
75
+ document.body.style.cursor = "col-resize";
76
+ return () => {
77
+ window.removeEventListener("pointermove", onPointerMove);
78
+ window.removeEventListener("pointerup", onPointerUp);
79
+ document.body.style.userSelect = previousUserSelect;
80
+ document.body.style.cursor = previousCursor;
81
+ };
82
+ }, [isResizingSidebar, resizeSidebarWithClientX]);
83
+
84
+ const handlePaneScroll = useCallback((event) => {
85
+ const nextScrolled = event.currentTarget.scrollTop > 0;
86
+ setMobileTopbarScrolled((currentScrolled) =>
87
+ currentScrolled === nextScrolled ? currentScrolled : nextScrolled,
88
+ );
89
+ }, []);
90
+
91
+ return {
92
+ refs: {
93
+ appShellRef,
94
+ menuRef,
95
+ },
96
+ state: {
97
+ isResizingSidebar,
98
+ menuOpen,
99
+ mobileSidebarOpen,
100
+ mobileTopbarScrolled,
101
+ sidebarWidthPx,
102
+ },
103
+ actions: {
104
+ closeMobileSidebar: () => setMobileSidebarOpen(false),
105
+ handlePaneScroll,
106
+ onSidebarResizerPointerDown,
107
+ onToggleMenu: () => setMenuOpen((open) => !open),
108
+ setMenuOpen,
109
+ setMobileSidebarOpen,
110
+ },
111
+ };
112
+ };