@chrysb/alphaclaw 0.4.2 → 0.4.4

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.
@@ -31,6 +31,11 @@ const kSizeClassBySize = {
31
31
  md: "h-9 text-sm font-medium leading-none px-4 rounded-xl",
32
32
  lg: "h-10 text-sm font-medium leading-none px-5 rounded-lg",
33
33
  };
34
+ const kIconOnlySizeClassBySize = {
35
+ sm: "h-7 w-7 p-0 rounded-lg",
36
+ md: "h-9 w-9 p-0 rounded-xl",
37
+ lg: "h-10 w-10 p-0 rounded-lg",
38
+ };
34
39
 
35
40
  export const ActionButton = ({
36
41
  onClick,
@@ -45,11 +50,16 @@ export const ActionButton = ({
45
50
  className = "",
46
51
  idleIcon = null,
47
52
  idleIconClassName = "h-3 w-3",
53
+ iconOnly = false,
54
+ title = "",
55
+ ariaLabel = "",
48
56
  }) => {
49
57
  const isDisabled = disabled || loading;
50
58
  const isInteractive = !isDisabled;
51
59
  const toneClass = getToneClass(tone, isInteractive);
52
- const sizeClass = kSizeClassBySize[size] || kSizeClassBySize.sm;
60
+ const sizeClass = iconOnly
61
+ ? kIconOnlySizeClassBySize[size] || kIconOnlySizeClassBySize.sm
62
+ : kSizeClassBySize[size] || kSizeClassBySize.sm;
53
63
  const loadingClass = loading
54
64
  ? `cursor-not-allowed ${
55
65
  tone === "warning"
@@ -60,7 +70,9 @@ export const ActionButton = ({
60
70
  const spinnerSizeClass = size === "md" || size === "lg" ? "h-4 w-4" : "h-3 w-3";
61
71
  const isInlineLoading = loadingMode === "inline";
62
72
  const IdleIcon = idleIcon;
63
- const idleContent = IdleIcon
73
+ const idleContent = iconOnly && IdleIcon
74
+ ? html`<${IdleIcon} className=${idleIconClassName} />`
75
+ : IdleIcon
64
76
  ? html`
65
77
  <span class="inline-flex items-center gap-1.5 leading-none">
66
78
  <${IdleIcon} className=${idleIconClassName} />
@@ -75,6 +87,8 @@ export const ActionButton = ({
75
87
  type=${type}
76
88
  onclick=${onClick}
77
89
  disabled=${isDisabled}
90
+ title=${title}
91
+ aria-label=${ariaLabel || null}
78
92
  class="inline-flex items-center justify-center transition-colors whitespace-nowrap ${sizeClass} ${toneClass} ${loadingClass} ${className}"
79
93
  >
80
94
  ${isInlineLoading
@@ -17,6 +17,17 @@ const kGroupLabels = {
17
17
 
18
18
  const kGroupOrder = ["github", "channels", "tools", "custom"];
19
19
  const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
20
+ const stripSurroundingQuotes = (raw) => {
21
+ const value = String(raw || "").trim();
22
+ if (value.length < 2) return value;
23
+ const startsWithDouble = value.startsWith('"');
24
+ const endsWithDouble = value.endsWith('"');
25
+ if (startsWithDouble && endsWithDouble) return value.slice(1, -1);
26
+ const startsWithSingle = value.startsWith("'");
27
+ const endsWithSingle = value.endsWith("'");
28
+ if (startsWithSingle && endsWithSingle) return value.slice(1, -1);
29
+ return value;
30
+ };
20
31
  const getVarsSignature = (items) =>
21
32
  JSON.stringify(
22
33
  (items || [])
@@ -27,6 +38,20 @@ const getVarsSignature = (items) =>
27
38
  .sort((a, b) => a.key.localeCompare(b.key)),
28
39
  );
29
40
 
41
+ const sortCustomVarsAlphabetically = (items) => {
42
+ const list = Array.isArray(items) ? [...items] : [];
43
+ const customSorted = list
44
+ .filter((item) => (item?.group || "custom") === "custom")
45
+ .sort((a, b) => String(a?.key || "").localeCompare(String(b?.key || "")));
46
+ let customIdx = 0;
47
+ return list.map((item) => {
48
+ if ((item?.group || "custom") !== "custom") return item;
49
+ const next = customSorted[customIdx];
50
+ customIdx += 1;
51
+ return next;
52
+ });
53
+ };
54
+
30
55
  const kHintByKey = {
31
56
  ANTHROPIC_API_KEY: html`from <a href="https://console.anthropic.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.anthropic.com</a>`,
32
57
  ANTHROPIC_TOKEN: html`from <code class="text-xs bg-black/30 px-1 rounded">claude setup-token</code>`,
@@ -85,6 +110,8 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
85
110
  export const Envars = ({ onRestartRequired = () => {} }) => {
86
111
  const [vars, setVars] = useState([]);
87
112
  const [reservedKeys, setReservedKeys] = useState(() => new Set());
113
+ const [pendingCustomKeys, setPendingCustomKeys] = useState([]);
114
+ const [secretMaskEpoch, setSecretMaskEpoch] = useState(0);
88
115
  const [dirty, setDirty] = useState(false);
89
116
  const [saving, setSaving] = useState(false);
90
117
  const [newKey, setNewKey] = useState("");
@@ -93,9 +120,10 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
93
120
  const load = useCallback(async () => {
94
121
  try {
95
122
  const data = await fetchEnvVars();
96
- const nextVars = data.vars || [];
123
+ const nextVars = sortCustomVarsAlphabetically(data.vars || []);
97
124
  baselineSignatureRef.current = getVarsSignature(nextVars);
98
125
  setVars(nextVars);
126
+ setPendingCustomKeys([]);
99
127
  setReservedKeys(new Set(data.reservedKeys || []));
100
128
  onRestartRequired(!!data.restartRequired);
101
129
  } catch (err) {
@@ -117,6 +145,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
117
145
 
118
146
  const handleDelete = (key) => {
119
147
  setVars((prev) => prev.filter((v) => v.key !== key));
148
+ setPendingCustomKeys((prev) => prev.filter((pendingKey) => pendingKey !== key));
120
149
  };
121
150
 
122
151
  const handleSave = async () => {
@@ -135,7 +164,11 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
135
164
  : "Environment variables saved",
136
165
  "success",
137
166
  );
138
- baselineSignatureRef.current = getVarsSignature(vars);
167
+ const sortedVars = sortCustomVarsAlphabetically(vars);
168
+ setVars(sortedVars);
169
+ setPendingCustomKeys([]);
170
+ setSecretMaskEpoch((prev) => prev + 1);
171
+ baselineSignatureRef.current = getVarsSignature(sortedVars);
139
172
  setDirty(false);
140
173
  } catch (err) {
141
174
  showToast("Failed to save: " + err.message, "error");
@@ -158,7 +191,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
158
191
  if (eqIdx > 0)
159
192
  pairs.push({
160
193
  key: line.slice(0, eqIdx).trim(),
161
- value: line.slice(eqIdx + 1).trim(),
194
+ value: stripSurroundingQuotes(line.slice(eqIdx + 1)),
162
195
  });
163
196
  }
164
197
  return pairs;
@@ -167,6 +200,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
167
200
  const addVars = (pairs) => {
168
201
  let added = 0;
169
202
  const blocked = [];
203
+ const addedCustomKeys = [];
170
204
  setVars((prev) => {
171
205
  const next = [...prev];
172
206
  for (const { key: rawKey, value } of pairs) {
@@ -189,11 +223,15 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
189
223
  source: "env_file",
190
224
  editable: true,
191
225
  });
226
+ addedCustomKeys.push(key);
192
227
  }
193
228
  added++;
194
229
  }
195
230
  return next;
196
231
  });
232
+ if (addedCustomKeys.length) {
233
+ setPendingCustomKeys((prev) => [...prev, ...addedCustomKeys]);
234
+ }
197
235
  return { added, blocked };
198
236
  };
199
237
 
@@ -264,6 +302,14 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
264
302
  if (!grouped[g]) grouped[g] = [];
265
303
  grouped[g].push(v);
266
304
  }
305
+ if (grouped.custom?.length) {
306
+ const pending = new Set(pendingCustomKeys);
307
+ const nonPending = grouped.custom
308
+ .filter((item) => !pending.has(item.key))
309
+ .sort((a, b) => String(a?.key || "").localeCompare(String(b?.key || "")));
310
+ const pendingAtBottom = grouped.custom.filter((item) => pending.has(item.key));
311
+ grouped.custom = [...nonPending, ...pendingAtBottom];
312
+ }
267
313
 
268
314
  return html`
269
315
  <div class="space-y-4">
@@ -295,6 +341,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
295
341
  ${grouped[g].map(
296
342
  (v) =>
297
343
  html`<${EnvRow}
344
+ key=${`${secretMaskEpoch}:${v.key}`}
298
345
  envVar=${v}
299
346
  onChange=${handleChange}
300
347
  onDelete=${handleDelete}
@@ -13,6 +13,7 @@ import {
13
13
  createBrowseFile,
14
14
  createBrowseFolder,
15
15
  moveBrowsePath,
16
+ downloadBrowseFile,
16
17
  } from "../lib/api.js";
17
18
  import {
18
19
  kDraftIndexChangedEventName,
@@ -40,6 +41,7 @@ import {
40
41
  FileAddLineIcon,
41
42
  FolderAddLineIcon,
42
43
  DeleteBinLineIcon,
44
+ DownloadLineIcon,
43
45
  } from "./icons.js";
44
46
  import { LoadingSpinner } from "./loading-spinner.js";
45
47
  import { ConfirmDialog } from "./confirm-dialog.js";
@@ -224,6 +226,7 @@ const TreeContextMenu = ({
224
226
  isLocked,
225
227
  onNewFile,
226
228
  onNewFolder,
229
+ onDownload,
227
230
  onDelete,
228
231
  onClose,
229
232
  }) => {
@@ -252,6 +255,7 @@ const TreeContextMenu = ({
252
255
  const isRoot = targetType === "root";
253
256
  const contextFolder = isFolder ? targetPath : "";
254
257
  const canCreate = !isLocked && (isFolder || isRoot);
258
+ const canDownload = isFile && targetPath;
255
259
  const canDelete = !isLocked && (isFolder || isFile) && targetPath;
256
260
 
257
261
  return html`
@@ -278,18 +282,33 @@ const TreeContextMenu = ({
278
282
  </button>
279
283
  `
280
284
  : null}
281
- ${canDelete
285
+ ${canDownload || canDelete
282
286
  ? html`
283
287
  ${canCreate
284
288
  ? html`<div class="tree-context-menu-sep"></div>`
285
289
  : null}
286
- <button
287
- class="tree-context-menu-item"
288
- onclick=${() => { onDelete(targetPath); onClose(); }}
289
- >
290
- <${DeleteBinLineIcon} className="tree-context-menu-icon" />
291
- <span>Delete</span>
292
- </button>
290
+ ${canDownload
291
+ ? html`
292
+ <button
293
+ class="tree-context-menu-item"
294
+ onclick=${() => { onDownload(targetPath); onClose(); }}
295
+ >
296
+ <${DownloadLineIcon} className="tree-context-menu-icon" />
297
+ <span>Download</span>
298
+ </button>
299
+ `
300
+ : null}
301
+ ${canDelete
302
+ ? html`
303
+ <button
304
+ class="tree-context-menu-item"
305
+ onclick=${() => { onDelete(targetPath); onClose(); }}
306
+ >
307
+ <${DeleteBinLineIcon} className="tree-context-menu-icon" />
308
+ <span>Delete</span>
309
+ </button>
310
+ `
311
+ : null}
293
312
  `
294
313
  : null}
295
314
  ${isLocked
@@ -942,6 +961,15 @@ export const FileTree = ({
942
961
  setContextMenu(null);
943
962
  };
944
963
 
964
+ const requestDownload = async (targetPath) => {
965
+ try {
966
+ await downloadBrowseFile(targetPath);
967
+ showToast("Download started", "success");
968
+ } catch (downloadError) {
969
+ showToast(downloadError.message || "Could not download file", "error");
970
+ }
971
+ };
972
+
945
973
  const handleDragDrop = async (action, sourcePath, targetFolder) => {
946
974
  if (action === "start") {
947
975
  setDragSourcePath(sourcePath);
@@ -1208,6 +1236,7 @@ export const FileTree = ({
1208
1236
  isLocked=${!!contextMenu.isLocked}
1209
1237
  onNewFile=${(folder) => requestCreate(folder, "file")}
1210
1238
  onNewFolder=${(folder) => requestCreate(folder, "folder")}
1239
+ onDownload=${requestDownload}
1211
1240
  onDelete=${requestDelete}
1212
1241
  onClose=${closeContextMenu}
1213
1242
  />
@@ -39,18 +39,25 @@ export const FileViewerToolbar = ({
39
39
  ${pathSegments.map(
40
40
  (segment, index) => html`
41
41
  <span class="file-viewer-breadcrumb-item">
42
- <span class=${index === pathSegments.length - 1 ? "is-current" : ""}>
42
+ <span
43
+ class=${index === pathSegments.length - 1 ? "is-current" : ""}
44
+ >
43
45
  ${segment}
44
46
  </span>
45
- ${index < pathSegments.length - 1 && html`<span class="file-viewer-sep">></span>`}
47
+ ${index < pathSegments.length - 1 &&
48
+ html`<span class="file-viewer-sep">></span>`}
46
49
  </span>
47
50
  `,
48
51
  )}
49
52
  </span>
50
- ${isDirty ? html`<span class="file-viewer-dirty-dot" aria-hidden="true"></span>` : null}
53
+ ${isDirty
54
+ ? html`<span class="file-viewer-dirty-dot" aria-hidden="true"></span>`
55
+ : null}
51
56
  </div>
52
57
  <div class="file-viewer-tabbar-spacer"></div>
53
- ${isPreviewOnly ? html`<div class="file-viewer-preview-pill">Preview</div>` : null}
58
+ ${isPreviewOnly
59
+ ? html`<div class="file-viewer-preview-pill">Preview</div>`
60
+ : null}
54
61
  ${!isDiffView &&
55
62
  isMarkdownFile &&
56
63
  html`
@@ -69,27 +76,32 @@ export const FileViewerToolbar = ({
69
76
  ? html`
70
77
  ${!isProtectedFile
71
78
  ? html`
72
- <button
73
- type="button"
74
- onclick=${onRequestDelete}
79
+ <${ActionButton}
80
+ onClick=${onRequestDelete}
75
81
  disabled=${!canDeleteFile || deleting}
76
- class=${`file-viewer-icon-action file-viewer-delete-action ${
77
- !canDeleteFile || deleting ? "is-disabled" : ""
78
- }`.trim()}
82
+ tone="secondary"
83
+ size="sm"
84
+ iconOnly=${true}
85
+ idleLabel=""
86
+ idleIcon=${DeleteBinLineIcon}
87
+ idleIconClassName="file-viewer-icon-action-icon"
88
+ className="file-viewer-save-action"
79
89
  title=${isDeleteBlocked
80
90
  ? "Locked files cannot be deleted"
81
91
  : "Delete file"}
82
- aria-label="Delete file"
83
- >
84
- <${DeleteBinLineIcon} className="file-viewer-icon-action-icon" />
85
- </button>
92
+ ariaLabel="Delete file"
93
+ />
86
94
  `
87
95
  : null}
88
96
  ${isDirty
89
97
  ? html`
90
98
  <${ActionButton}
91
99
  onClick=${handleDiscard}
92
- disabled=${loading || !canEditFile || isEditBlocked || deleting || saving}
100
+ disabled=${loading ||
101
+ !canEditFile ||
102
+ isEditBlocked ||
103
+ deleting ||
104
+ saving}
93
105
  tone="secondary"
94
106
  size="sm"
95
107
  idleLabel="Discard changes"
@@ -1,10 +1,7 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
- import {
5
- fetchOpenclawVersion,
6
- updateOpenclaw,
7
- } from "../lib/api.js";
4
+ import { fetchOpenclawVersion, updateOpenclaw } from "../lib/api.js";
8
5
  import { UpdateActionButton } from "./update-action-button.js";
9
6
  import { ConfirmDialog } from "./confirm-dialog.js";
10
7
  import { showToast } from "./toast.js";
@@ -38,7 +35,8 @@ const VersionRow = ({
38
35
  const [hasUpdate, setHasUpdate] = useState(false);
39
36
  const [error, setError] = useState("");
40
37
  const [hasViewedChangelog, setHasViewedChangelog] = useState(false);
41
- const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] = useState(false);
38
+ const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] =
39
+ useState(false);
42
40
  const simulateUpdate = (() => {
43
41
  try {
44
42
  const params = new URLSearchParams(window.location.search);
@@ -124,7 +122,9 @@ const VersionRow = ({
124
122
  setChecking(true);
125
123
  setError("");
126
124
  try {
127
- const data = isUpdateAction ? await applyUpdate() : await fetchVersion(true);
125
+ const data = isUpdateAction
126
+ ? await applyUpdate()
127
+ : await fetchVersion(true);
128
128
  setVersion(data.currentVersion || version);
129
129
  setLatestVersion(data.latestVersion || null);
130
130
  setHasUpdate(!!data.hasUpdate);
@@ -158,10 +158,14 @@ const VersionRow = ({
158
158
  } catch (err) {
159
159
  setError(
160
160
  err.message ||
161
- (isUpdateAction ? `Could not update ${label}` : "Could not check updates"),
161
+ (isUpdateAction
162
+ ? `Could not update ${label}`
163
+ : "Could not check updates"),
162
164
  );
163
165
  showToast(
164
- isUpdateAction ? `Could not update ${label}` : "Could not check updates",
166
+ isUpdateAction
167
+ ? `Could not update ${label}`
168
+ : "Could not check updates",
165
169
  "error",
166
170
  );
167
171
  await onActionComplete({
@@ -201,7 +205,8 @@ const VersionRow = ({
201
205
  ? `${version}`
202
206
  : "..."}
203
207
  </p>
204
- ${error && effectiveHasUpdate &&
208
+ ${error &&
209
+ effectiveHasUpdate &&
205
210
  html`<div
206
211
  class="mt-1 text-xs text-red-300 bg-red-900/30 border border-red-800 rounded-lg px-2 py-1"
207
212
  >
@@ -209,7 +214,9 @@ const VersionRow = ({
209
214
  </div>`}
210
215
  </div>
211
216
  <div class="flex items-center gap-3 shrink-0">
212
- ${effectiveHasUpdate && effectiveLatestVersion && html`
217
+ ${effectiveHasUpdate &&
218
+ effectiveLatestVersion &&
219
+ html`
213
220
  <a
214
221
  href=${changelogUrl}
215
222
  target="_blank"
@@ -228,7 +235,9 @@ const VersionRow = ({
228
235
  idleLabel=${isUpdateActionActive
229
236
  ? updateIdleLabel
230
237
  : "Check updates"}
231
- loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
238
+ loadingLabel=${isUpdateActionActive
239
+ ? "Updating..."
240
+ : "Checking..."}
232
241
  className="hidden md:inline-flex"
233
242
  />
234
243
  `
@@ -240,12 +249,15 @@ const VersionRow = ({
240
249
  idleLabel=${isUpdateActionActive
241
250
  ? updateIdleLabel
242
251
  : "Check updates"}
243
- loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
252
+ loadingLabel=${isUpdateActionActive
253
+ ? "Updating..."
254
+ : "Checking..."}
244
255
  />
245
256
  `}
246
257
  </div>
247
258
  </div>
248
- ${showMobileUpdateRow && html`
259
+ ${showMobileUpdateRow &&
260
+ html`
249
261
  <div class="mt-2 md:hidden flex items-center gap-2">
250
262
  <a
251
263
  href=${changelogUrl}
@@ -296,24 +308,25 @@ export const Gateway = ({
296
308
  const dotClass = isRunning
297
309
  ? "ac-status-dot ac-status-dot--healthy"
298
310
  : "w-2 h-2 rounded-full bg-yellow-500 animate-pulse";
299
- const watchdogHealth = watchdogStatus?.lifecycle === "crash_loop"
300
- ? "crash_loop"
301
- : watchdogStatus?.health;
302
- const watchdogDotClass = watchdogHealth === "healthy"
303
- ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
304
- : watchdogHealth === "degraded"
305
- ? "bg-yellow-500"
306
- : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
307
- ? "bg-red-500"
308
- : "bg-gray-500";
309
- const watchdogLabel = watchdogHealth === "unknown"
310
- ? "initializing"
311
- : watchdogHealth || "unknown";
311
+ const watchdogHealth =
312
+ watchdogStatus?.lifecycle === "crash_loop"
313
+ ? "crash_loop"
314
+ : watchdogStatus?.health;
315
+ const watchdogDotClass =
316
+ watchdogHealth === "healthy"
317
+ ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
318
+ : watchdogHealth === "degraded"
319
+ ? "bg-yellow-500"
320
+ : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
321
+ ? "bg-red-500"
322
+ : "bg-gray-500";
323
+ const watchdogLabel =
324
+ watchdogHealth === "unknown" ? "initializing" : watchdogHealth || "unknown";
312
325
  const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress;
326
+ const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog;
313
327
  const showRepairButton =
314
328
  isRepairInProgress ||
315
329
  watchdogStatus?.lifecycle === "crash_loop" ||
316
- watchdogStatus?.health === "degraded" ||
317
330
  watchdogStatus?.health === "unhealthy" ||
318
331
  watchdogStatus?.health === "crashed";
319
332
  const liveUptimeMs = useMemo(() => {
@@ -339,7 +352,9 @@ export const Gateway = ({
339
352
  <div class="min-w-0 flex items-center gap-2 text-sm">
340
353
  <span class=${dotClass}></span>
341
354
  <span class="font-semibold">Gateway:</span>
342
- <span class="text-gray-400">${restarting ? "restarting..." : status || "checking..."}</span>
355
+ <span class="text-gray-400"
356
+ >${restarting ? "restarting..." : status || "checking..."}</span
357
+ >
343
358
  </div>
344
359
  <div class="flex items-center gap-3 shrink-0">
345
360
  ${!restarting && isRunning
@@ -367,18 +382,22 @@ export const Gateway = ({
367
382
  onclick=${onOpenWatchdog}
368
383
  title="Open Watchdog tab"
369
384
  >
370
- <span class=${watchdogDotClass.startsWith("ac-status-dot")
371
- ? watchdogDotClass
372
- : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
385
+ <span
386
+ class=${watchdogDotClass.startsWith("ac-status-dot")
387
+ ? watchdogDotClass
388
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}
389
+ ></span>
373
390
  <span class="font-semibold">Watchdog:</span>
374
391
  <span class="text-gray-400">${watchdogLabel}</span>
375
392
  </button>
376
393
  `
377
394
  : html`
378
395
  <div class="inline-flex items-center gap-2 text-sm">
379
- <span class=${watchdogDotClass.startsWith("ac-status-dot")
380
- ? watchdogDotClass
381
- : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
396
+ <span
397
+ class=${watchdogDotClass.startsWith("ac-status-dot")
398
+ ? watchdogDotClass
399
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}
400
+ ></span>
382
401
  <span class="font-semibold">Watchdog:</span>
383
402
  <span class="text-gray-400">${watchdogLabel}</span>
384
403
  </div>
@@ -386,24 +405,37 @@ export const Gateway = ({
386
405
  ${onRepair
387
406
  ? html`
388
407
  <div class="shrink-0 w-32 flex justify-end">
389
- ${showRepairButton
408
+ ${showInspectButton
390
409
  ? html`
391
410
  <${UpdateActionButton}
392
- onClick=${onRepair}
393
- loading=${isRepairInProgress}
394
- warning=${true}
395
- idleLabel="Repair"
396
- loadingLabel="Repairing..."
411
+ onClick=${onOpenWatchdog}
412
+ warning=${false}
413
+ idleLabel="Inspect"
414
+ loadingLabel="Inspect"
397
415
  className="w-full justify-center"
398
416
  />
399
417
  `
400
- : html`<span class="inline-flex h-7 w-full" aria-hidden="true"></span>`}
418
+ : showRepairButton
419
+ ? html`
420
+ <${UpdateActionButton}
421
+ onClick=${onRepair}
422
+ loading=${isRepairInProgress}
423
+ warning=${true}
424
+ idleLabel="Repair"
425
+ loadingLabel="Repairing..."
426
+ className="w-full justify-center"
427
+ />
428
+ `
429
+ : html`<span
430
+ class="inline-flex h-7 w-full"
431
+ aria-hidden="true"
432
+ ></span>`}
401
433
  </div>
402
434
  `
403
435
  : null}
404
436
  </div>
405
437
  </div>
406
- <div class="mt-3 pt-3 border-t border-border">
438
+ <div class="mt-3">
407
439
  <${VersionRow}
408
440
  label="OpenClaw"
409
441
  currentVersion=${openclawVersion}
@@ -288,6 +288,19 @@ export const FolderAddLineIcon = ({ className = "" }) => html`
288
288
  </svg>
289
289
  `;
290
290
 
291
+ export const DownloadLineIcon = ({ className = "" }) => html`
292
+ <svg
293
+ class=${className}
294
+ viewBox="0 0 24 24"
295
+ fill="currentColor"
296
+ aria-hidden="true"
297
+ >
298
+ <path
299
+ d="M3 19H21V21H3V19ZM13 13.1716L19.0711 7.1005L20.4853 8.51472L12 17L3.51472 8.51472L4.92893 7.1005L11 13.1716V2H13V13.1716Z"
300
+ />
301
+ </svg>
302
+ `;
303
+
291
304
  export const RestartLineIcon = ({ className = "" }) => html`
292
305
  <svg
293
306
  class=${className}