@chrysb/alphaclaw 0.3.5-beta.0 → 0.3.5-beta.1

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 (44) hide show
  1. package/bin/alphaclaw.js +65 -1
  2. package/lib/public/css/explorer.css +201 -6
  3. package/lib/public/js/app.js +45 -1
  4. package/lib/public/js/components/channels.js +1 -0
  5. package/lib/public/js/components/file-tree.js +56 -67
  6. package/lib/public/js/components/file-viewer/constants.js +6 -0
  7. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  8. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  9. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  10. package/lib/public/js/components/file-viewer/index.js +164 -0
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  12. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  13. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  14. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  15. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  16. package/lib/public/js/components/file-viewer/storage.js +58 -0
  17. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  18. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  19. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  20. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  21. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  24. package/lib/public/js/components/file-viewer/utils.js +11 -0
  25. package/lib/public/js/components/gateway.js +83 -30
  26. package/lib/public/js/components/icons.js +13 -0
  27. package/lib/public/js/components/sidebar-git-panel.js +72 -11
  28. package/lib/public/js/components/usage-tab.js +4 -1
  29. package/lib/public/js/components/watchdog-tab.js +6 -0
  30. package/lib/public/js/lib/api.js +16 -0
  31. package/lib/public/js/lib/browse-file-policies.js +34 -0
  32. package/lib/scripts/git +40 -0
  33. package/lib/scripts/git-askpass +6 -0
  34. package/lib/server/constants.js +8 -0
  35. package/lib/server/routes/browse/constants.js +51 -0
  36. package/lib/server/routes/browse/file-helpers.js +43 -0
  37. package/lib/server/routes/browse/git.js +131 -0
  38. package/lib/server/routes/{browse.js → browse/index.js} +290 -218
  39. package/lib/server/routes/browse/path-utils.js +53 -0
  40. package/lib/server/routes/browse/sqlite.js +140 -0
  41. package/lib/server/routes/proxy.js +11 -5
  42. package/lib/setup/core-prompts/TOOLS.md +0 -4
  43. package/package.json +1 -1
  44. package/lib/public/js/components/file-viewer.js +0 -1095
@@ -0,0 +1,379 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
2
+ import { marked } from "https://esm.sh/marked";
3
+ import { saveFileContent } from "../../lib/api.js";
4
+ import {
5
+ getFileSyntaxKind,
6
+ highlightEditorLines,
7
+ parseFrontmatter,
8
+ } from "../../lib/syntax-highlighters/index.js";
9
+ import {
10
+ clearStoredFileDraft,
11
+ updateDraftIndex,
12
+ writeStoredFileDraft,
13
+ } from "../../lib/browse-draft-state.js";
14
+ import {
15
+ kLockedBrowsePaths,
16
+ kProtectedBrowsePaths,
17
+ matchesBrowsePolicyPath,
18
+ normalizeBrowsePolicyPath,
19
+ } from "../../lib/browse-file-policies.js";
20
+ import { showToast } from "../toast.js";
21
+ import { kFileViewerModeStorageKey, kLoadingIndicatorDelayMs } from "./constants.js";
22
+ import { readStoredFileViewerMode, writeStoredEditorSelection } from "./storage.js";
23
+ import { parsePathSegments } from "./utils.js";
24
+ import { useScrollSync } from "./scroll-sync.js";
25
+ import { useFileLoader } from "./use-file-loader.js";
26
+ import { useFileDiff } from "./use-file-diff.js";
27
+ import { useFileViewerDraftSync } from "./use-file-viewer-draft-sync.js";
28
+ import { useFileViewerHotkeys } from "./use-file-viewer-hotkeys.js";
29
+ import { useEditorSelectionRestore } from "./use-editor-selection-restore.js";
30
+
31
+ export const useFileViewer = ({
32
+ filePath = "",
33
+ isPreviewOnly = false,
34
+ browseView = "edit",
35
+ }) => {
36
+ const normalizedPath = String(filePath || "").trim();
37
+ const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath);
38
+ const [content, setContent] = useState("");
39
+ const [initialContent, setInitialContent] = useState("");
40
+ const [fileKind, setFileKind] = useState("text");
41
+ const [imageDataUrl, setImageDataUrl] = useState("");
42
+ const [audioDataUrl, setAudioDataUrl] = useState("");
43
+ const [sqliteSummary, setSqliteSummary] = useState(null);
44
+ const [sqliteSelectedTable, setSqliteSelectedTable] = useState("");
45
+ const [sqliteTableOffset, setSqliteTableOffset] = useState(0);
46
+ const [sqliteTableLoading, setSqliteTableLoading] = useState(false);
47
+ const [sqliteTableError, setSqliteTableError] = useState("");
48
+ const [sqliteTableData, setSqliteTableData] = useState(null);
49
+ const [viewMode, setViewMode] = useState(readStoredFileViewerMode);
50
+ const [loading, setLoading] = useState(false);
51
+ const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false);
52
+ const [saving, setSaving] = useState(false);
53
+ const [error, setError] = useState("");
54
+ const [isFolderPath, setIsFolderPath] = useState(false);
55
+ const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false);
56
+ const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false);
57
+ const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set());
58
+ const editorLineNumbersRef = useRef(null);
59
+ const editorHighlightRef = useRef(null);
60
+ const editorTextareaRef = useRef(null);
61
+ const previewRef = useRef(null);
62
+ const editorLineNumberRowRefs = useRef([]);
63
+ const editorHighlightLineRefs = useRef([]);
64
+
65
+ const hasSelectedPath = normalizedPath.length > 0;
66
+ const isImageFile = fileKind === "image";
67
+ const isAudioFile = fileKind === "audio";
68
+ const isSqliteFile = fileKind === "sqlite";
69
+ const canEditFile =
70
+ hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile;
71
+ const isDiffView = String(browseView || "edit") === "diff";
72
+
73
+ const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } =
74
+ useScrollSync({
75
+ viewMode,
76
+ setViewMode,
77
+ previewRef,
78
+ editorTextareaRef,
79
+ editorLineNumbersRef,
80
+ editorHighlightRef,
81
+ });
82
+
83
+ const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({
84
+ hasSelectedPath,
85
+ normalizedPath,
86
+ isSqliteFile,
87
+ sqliteSelectedTable,
88
+ sqliteTableOffset,
89
+ canEditFile,
90
+ isFolderPath,
91
+ loading,
92
+ saving,
93
+ initialContent,
94
+ isDirty: canEditFile && content !== initialContent,
95
+ setLoading,
96
+ setContent,
97
+ setInitialContent,
98
+ setFileKind,
99
+ setImageDataUrl,
100
+ setAudioDataUrl,
101
+ setSqliteSummary,
102
+ setSqliteSelectedTable,
103
+ setSqliteTableOffset,
104
+ setSqliteTableLoading,
105
+ setSqliteTableError,
106
+ setSqliteTableData,
107
+ setError,
108
+ setIsFolderPath,
109
+ setExternalChangeNoticeShown,
110
+ externalChangeNoticeShown,
111
+ viewScrollRatioRef,
112
+ });
113
+
114
+ const { diffLoading, diffError, diffContent } = useFileDiff({
115
+ hasSelectedPath,
116
+ isDiffView,
117
+ isPreviewOnly,
118
+ normalizedPath,
119
+ });
120
+
121
+ const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]);
122
+ const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath;
123
+ const renderContent = isCurrentFileLoaded ? content : "";
124
+ const renderInitialContent = isCurrentFileLoaded ? initialContent : "";
125
+ const isDirty = canEditFile && renderContent !== renderInitialContent;
126
+ const isLockedFile =
127
+ canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath);
128
+ const isProtectedFile =
129
+ canEditFile &&
130
+ !isLockedFile &&
131
+ matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath);
132
+ const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath);
133
+ const isEditBlocked = isLockedFile || isProtectedLocked;
134
+ const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]);
135
+ const isMarkdownFile = syntaxKind === "markdown";
136
+ const shouldUseHighlightedEditor = syntaxKind !== "plain";
137
+ const parsedFrontmatter = useMemo(
138
+ () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }),
139
+ [renderContent, isMarkdownFile],
140
+ );
141
+ const highlightedEditorLines = useMemo(
142
+ () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []),
143
+ [renderContent, shouldUseHighlightedEditor, syntaxKind],
144
+ );
145
+ const editorLineNumbers = useMemo(() => {
146
+ const lineCount = String(renderContent || "").split("\n").length;
147
+ return Array.from({ length: lineCount }, (_, index) => index + 1);
148
+ }, [renderContent]);
149
+ const previewHtml = useMemo(
150
+ () =>
151
+ isMarkdownFile
152
+ ? marked.parse(parsedFrontmatter.body || "", {
153
+ gfm: true,
154
+ breaks: true,
155
+ })
156
+ : "",
157
+ [parsedFrontmatter.body, isMarkdownFile],
158
+ );
159
+
160
+ const syncEditorLineNumberHeights = useCallback(() => {
161
+ if (!shouldUseHighlightedEditor || viewMode !== "edit") return;
162
+ const numberRows = editorLineNumberRowRefs.current;
163
+ const highlightRows = editorHighlightLineRefs.current;
164
+ const rowCount = Math.min(numberRows.length, highlightRows.length);
165
+ for (let index = 0; index < rowCount; index += 1) {
166
+ const numberRow = numberRows[index];
167
+ const highlightRow = highlightRows[index];
168
+ if (!numberRow || !highlightRow) continue;
169
+ numberRow.style.height = `${highlightRow.offsetHeight}px`;
170
+ }
171
+ }, [shouldUseHighlightedEditor, viewMode]);
172
+
173
+ useEffect(() => {
174
+ syncEditorLineNumberHeights();
175
+ }, [content, syncEditorLineNumberHeights]);
176
+
177
+ useEffect(() => {
178
+ if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {};
179
+ const onResize = () => syncEditorLineNumberHeights();
180
+ window.addEventListener("resize", onResize);
181
+ return () => window.removeEventListener("resize", onResize);
182
+ }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]);
183
+
184
+ useEffect(() => {
185
+ if (!isMarkdownFile && viewMode !== "edit") {
186
+ setViewMode("edit");
187
+ }
188
+ }, [isMarkdownFile, viewMode]);
189
+
190
+ useEffect(() => {
191
+ try {
192
+ window.localStorage.setItem(kFileViewerModeStorageKey, viewMode);
193
+ } catch {}
194
+ }, [viewMode]);
195
+
196
+ useEffect(() => {
197
+ if (!loading) {
198
+ setShowDelayedLoadingSpinner(false);
199
+ return () => {};
200
+ }
201
+ const timer = window.setTimeout(() => {
202
+ setShowDelayedLoadingSpinner(true);
203
+ }, kLoadingIndicatorDelayMs);
204
+ return () => window.clearTimeout(timer);
205
+ }, [loading]);
206
+
207
+ useFileViewerDraftSync({
208
+ loadedFilePathRef,
209
+ normalizedPath,
210
+ canEditFile,
211
+ hasSelectedPath,
212
+ loading,
213
+ content,
214
+ initialContent,
215
+ });
216
+
217
+ useEditorSelectionRestore({
218
+ canEditFile,
219
+ loading,
220
+ hasSelectedPath,
221
+ normalizedPath,
222
+ loadedFilePathRef,
223
+ restoredSelectionPathRef,
224
+ viewMode,
225
+ content,
226
+ editorTextareaRef,
227
+ editorLineNumbersRef,
228
+ editorHighlightRef,
229
+ viewScrollRatioRef,
230
+ });
231
+
232
+ const handleSave = useCallback(async () => {
233
+ if (!canEditFile || saving || !isDirty || isEditBlocked) return;
234
+ setSaving(true);
235
+ setError("");
236
+ try {
237
+ await saveFileContent(normalizedPath, content);
238
+ setInitialContent(content);
239
+ setExternalChangeNoticeShown(false);
240
+ clearStoredFileDraft(normalizedPath);
241
+ updateDraftIndex(normalizedPath, false, {
242
+ dispatchEvent: (event) => window.dispatchEvent(event),
243
+ });
244
+ window.dispatchEvent(
245
+ new CustomEvent("alphaclaw:browse-file-saved", {
246
+ detail: { path: normalizedPath },
247
+ }),
248
+ );
249
+ showToast("Saved", "success");
250
+ } catch (saveError) {
251
+ const message = saveError.message || "Could not save file";
252
+ setError(message);
253
+ showToast(message, "error");
254
+ } finally {
255
+ setSaving(false);
256
+ }
257
+ }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]);
258
+
259
+ useFileViewerHotkeys({
260
+ canEditFile,
261
+ isPreviewOnly,
262
+ isDiffView,
263
+ viewMode,
264
+ handleSave,
265
+ });
266
+
267
+ const handleEditProtectedFile = () => {
268
+ if (!normalizedPolicyPath) return;
269
+ setProtectedEditBypassPaths((previousPaths) => {
270
+ const nextPaths = new Set(previousPaths);
271
+ nextPaths.add(normalizedPolicyPath);
272
+ return nextPaths;
273
+ });
274
+ window.requestAnimationFrame(() => {
275
+ window.requestAnimationFrame(() => {
276
+ const textareaElement = editorTextareaRef.current;
277
+ if (!textareaElement) return;
278
+ if (textareaElement.disabled || textareaElement.readOnly) return;
279
+ textareaElement.focus();
280
+ });
281
+ });
282
+ };
283
+
284
+ const handleContentInput = (event) => {
285
+ if (isEditBlocked || isPreviewOnly) return;
286
+ const nextContent = event.target.value;
287
+ setContent(nextContent);
288
+ if (hasSelectedPath && canEditFile) {
289
+ writeStoredEditorSelection(normalizedPath, {
290
+ start: event.target.selectionStart,
291
+ end: event.target.selectionEnd,
292
+ });
293
+ }
294
+ if (hasSelectedPath && canEditFile) {
295
+ writeStoredFileDraft(normalizedPath, nextContent);
296
+ updateDraftIndex(normalizedPath, nextContent !== initialContent, {
297
+ dispatchEvent: (event) => window.dispatchEvent(event),
298
+ });
299
+ }
300
+ };
301
+
302
+ const handleEditorSelectionChange = () => {
303
+ if (!hasSelectedPath || !canEditFile || loading) return;
304
+ const textareaElement = editorTextareaRef.current;
305
+ if (!textareaElement) return;
306
+ writeStoredEditorSelection(normalizedPath, {
307
+ start: textareaElement.selectionStart,
308
+ end: textareaElement.selectionEnd,
309
+ });
310
+ };
311
+
312
+ return {
313
+ state: {
314
+ hasSelectedPath,
315
+ isPreviewOnly,
316
+ loading,
317
+ saving,
318
+ showDelayedLoadingSpinner,
319
+ error,
320
+ isFolderPath,
321
+ isImageFile,
322
+ imageDataUrl,
323
+ isAudioFile,
324
+ audioDataUrl,
325
+ isSqliteFile,
326
+ sqliteSummary,
327
+ sqliteSelectedTable,
328
+ sqliteTableOffset,
329
+ sqliteTableLoading,
330
+ sqliteTableError,
331
+ sqliteTableData,
332
+ isDiffView,
333
+ diffLoading,
334
+ diffError,
335
+ diffContent,
336
+ isMarkdownFile,
337
+ frontmatterCollapsed,
338
+ previewHtml,
339
+ viewMode,
340
+ renderContent,
341
+ },
342
+ derived: {
343
+ pathSegments,
344
+ isDirty,
345
+ canEditFile,
346
+ isEditBlocked,
347
+ isLockedFile,
348
+ isProtectedFile,
349
+ isProtectedLocked,
350
+ shouldUseHighlightedEditor,
351
+ parsedFrontmatter,
352
+ highlightedEditorLines,
353
+ editorLineNumbers,
354
+ },
355
+ refs: {
356
+ previewRef,
357
+ editorLineNumbersRef,
358
+ editorLineNumberRowRefs,
359
+ editorHighlightRef,
360
+ editorHighlightLineRefs,
361
+ editorTextareaRef,
362
+ },
363
+ actions: {
364
+ setFrontmatterCollapsed,
365
+ setSqliteSelectedTable,
366
+ setSqliteTableOffset,
367
+ handleChangeViewMode,
368
+ handleSave,
369
+ handleEditProtectedFile,
370
+ handleContentInput,
371
+ handleEditorScroll,
372
+ handlePreviewScroll,
373
+ handleEditorSelectionChange,
374
+ },
375
+ context: {
376
+ normalizedPath,
377
+ },
378
+ };
379
+ };
@@ -0,0 +1,11 @@
1
+ export const parsePathSegments = (inputPath) =>
2
+ String(inputPath || "")
3
+ .split("/")
4
+ .map((part) => part.trim())
5
+ .filter(Boolean);
6
+
7
+ export const clampSelectionIndex = (value, maxValue) => {
8
+ const numericValue = Number.parseInt(String(value ?? ""), 10);
9
+ if (!Number.isFinite(numericValue)) return 0;
10
+ return Math.max(0, Math.min(maxValue, numericValue));
11
+ };
@@ -24,7 +24,14 @@ const formatDuration = (ms) => {
24
24
  return `${seconds}s`;
25
25
  };
26
26
 
27
- function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
27
+ const VersionRow = ({
28
+ label,
29
+ currentVersion,
30
+ fetchVersion,
31
+ applyUpdate,
32
+ updateInProgress = false,
33
+ onActionComplete = () => {},
34
+ }) => {
28
35
  const [checking, setChecking] = useState(false);
29
36
  const [version, setVersion] = useState(currentVersion || null);
30
37
  const [latestVersion, setLatestVersion] = useState(null);
@@ -51,6 +58,10 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
51
58
  })();
52
59
  const effectiveHasUpdate = simulateUpdate || hasUpdate;
53
60
  const effectiveLatestVersion = simulatedVersion || latestVersion;
61
+ const isUpdateActionActive = updateInProgress || effectiveHasUpdate;
62
+ const updateIdleLabel = effectiveLatestVersion
63
+ ? `Update to ${effectiveLatestVersion}`
64
+ : "Update";
54
65
  const changelogUrl = "https://github.com/openclaw/openclaw/tags";
55
66
  const showMobileUpdateRow = effectiveHasUpdate && effectiveLatestVersion;
56
67
 
@@ -60,9 +71,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
60
71
 
61
72
  useEffect(() => {
62
73
  let active = true;
63
- const load = async () => {
74
+ const load = async (refresh = false) => {
64
75
  try {
65
- const data = await fetchVersion(false);
76
+ const data = await fetchVersion(refresh);
66
77
  if (!active) return;
67
78
  setVersion(data.currentVersion || currentVersion || null);
68
79
  setLatestVersion(data.latestVersion || null);
@@ -73,11 +84,30 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
73
84
  setError(err.message || "Could not check updates");
74
85
  }
75
86
  };
76
- load();
87
+ load(false);
77
88
  return () => {
78
89
  active = false;
79
90
  };
80
- }, []);
91
+ }, [currentVersion, fetchVersion]);
92
+
93
+ useEffect(() => {
94
+ if (updateInProgress) return () => {};
95
+ let active = true;
96
+ const timeoutId = setTimeout(async () => {
97
+ try {
98
+ const data = await fetchVersion(true);
99
+ if (!active) return;
100
+ setVersion(data.currentVersion || currentVersion || null);
101
+ setLatestVersion(data.latestVersion || null);
102
+ setHasUpdate(!!data.hasUpdate);
103
+ setError(data.ok ? "" : data.error || "");
104
+ } catch {}
105
+ }, 1200);
106
+ return () => {
107
+ active = false;
108
+ clearTimeout(timeoutId);
109
+ };
110
+ }, [updateInProgress, currentVersion, fetchVersion]);
81
111
 
82
112
  useEffect(() => {
83
113
  if (!effectiveHasUpdate || !effectiveLatestVersion) {
@@ -88,16 +118,18 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
88
118
  }, [effectiveHasUpdate, effectiveLatestVersion]);
89
119
 
90
120
  const runAction = async () => {
91
- if (checking) return;
121
+ const isUpdateAction = !!effectiveHasUpdate;
122
+ const busy = isUpdateActionActive ? checking || updateInProgress : checking;
123
+ if (busy) return;
92
124
  setChecking(true);
93
125
  setError("");
94
126
  try {
95
- const data = effectiveHasUpdate ? await applyUpdate() : await fetchVersion(true);
127
+ const data = isUpdateAction ? await applyUpdate() : await fetchVersion(true);
96
128
  setVersion(data.currentVersion || version);
97
129
  setLatestVersion(data.latestVersion || null);
98
130
  setHasUpdate(!!data.hasUpdate);
99
131
  setError(data.ok ? "" : data.error || "");
100
- if (effectiveHasUpdate) {
132
+ if (isUpdateAction) {
101
133
  if (!data.ok) {
102
134
  showToast(data.error || `${label} update failed`, "error");
103
135
  } else if (data.updated || data.restarting) {
@@ -118,21 +150,33 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
118
150
  } else {
119
151
  showToast(`${label} is up to date`, "success");
120
152
  }
153
+ await onActionComplete({
154
+ type: isUpdateAction ? "update" : "check",
155
+ ok: !!data?.ok,
156
+ result: data,
157
+ });
121
158
  } catch (err) {
122
159
  setError(
123
160
  err.message ||
124
- (effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates"),
161
+ (isUpdateAction ? `Could not update ${label}` : "Could not check updates"),
125
162
  );
126
163
  showToast(
127
- effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates",
164
+ isUpdateAction ? `Could not update ${label}` : "Could not check updates",
128
165
  "error",
129
166
  );
167
+ await onActionComplete({
168
+ type: isUpdateAction ? "update" : "check",
169
+ ok: false,
170
+ error: err,
171
+ });
172
+ } finally {
173
+ setChecking(false);
130
174
  }
131
- setChecking(false);
132
175
  };
133
176
 
134
177
  const handleAction = () => {
135
- if (checking) return;
178
+ const busy = isUpdateActionActive ? checking || updateInProgress : checking;
179
+ if (busy) return;
136
180
  if (effectiveHasUpdate && effectiveLatestVersion && !hasViewedChangelog) {
137
181
  setConfirmWithoutChangelogOpen(true);
138
182
  return;
@@ -145,6 +189,10 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
145
189
  runAction();
146
190
  };
147
191
 
192
+ const updateButtonLoading = isUpdateActionActive
193
+ ? checking || updateInProgress
194
+ : checking;
195
+
148
196
  return html`
149
197
  <div class="flex items-center justify-between gap-3">
150
198
  <div class="min-w-0">
@@ -175,24 +223,24 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
175
223
  ? html`
176
224
  <${UpdateActionButton}
177
225
  onClick=${handleAction}
178
- loading=${checking}
179
- warning=${effectiveHasUpdate}
180
- idleLabel=${effectiveHasUpdate
181
- ? `Update to ${effectiveLatestVersion || "latest"}`
226
+ loading=${updateButtonLoading}
227
+ warning=${isUpdateActionActive}
228
+ idleLabel=${isUpdateActionActive
229
+ ? updateIdleLabel
182
230
  : "Check updates"}
183
- loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."}
231
+ loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
184
232
  className="hidden md:inline-flex"
185
233
  />
186
234
  `
187
235
  : html`
188
236
  <${UpdateActionButton}
189
237
  onClick=${handleAction}
190
- loading=${checking}
191
- warning=${effectiveHasUpdate}
192
- idleLabel=${effectiveHasUpdate
193
- ? `Update to ${effectiveLatestVersion || "latest"}`
238
+ loading=${updateButtonLoading}
239
+ warning=${isUpdateActionActive}
240
+ idleLabel=${isUpdateActionActive
241
+ ? updateIdleLabel
194
242
  : "Check updates"}
195
- loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."}
243
+ loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
196
244
  />
197
245
  `}
198
246
  </div>
@@ -209,9 +257,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
209
257
  >
210
258
  <${UpdateActionButton}
211
259
  onClick=${handleAction}
212
- loading=${checking}
213
- warning=${effectiveHasUpdate}
214
- idleLabel=${`Update to ${effectiveLatestVersion || "latest"}`}
260
+ loading=${updateButtonLoading}
261
+ warning=${isUpdateActionActive}
262
+ idleLabel=${updateIdleLabel}
215
263
  loadingLabel="Updating..."
216
264
  className="flex-1 h-9 px-3"
217
265
  />
@@ -228,9 +276,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
228
276
  onConfirm=${handleConfirmWithoutChangelog}
229
277
  />
230
278
  `;
231
- }
279
+ };
232
280
 
233
- export function Gateway({
281
+ export const Gateway = ({
234
282
  status,
235
283
  openclawVersion,
236
284
  restarting = false,
@@ -239,7 +287,10 @@ export function Gateway({
239
287
  onOpenWatchdog,
240
288
  onRepair,
241
289
  repairing = false,
242
- }) {
290
+ openclawUpdateInProgress = false,
291
+ onOpenclawVersionActionComplete = () => {},
292
+ onOpenclawUpdate = updateOpenclaw,
293
+ }) => {
243
294
  const [nowMs, setNowMs] = useState(() => Date.now());
244
295
  const isRunning = status === "running" && !restarting;
245
296
  const dotClass = isRunning
@@ -357,8 +408,10 @@ export function Gateway({
357
408
  label="OpenClaw"
358
409
  currentVersion=${openclawVersion}
359
410
  fetchVersion=${fetchOpenclawVersion}
360
- applyUpdate=${updateOpenclaw}
411
+ applyUpdate=${onOpenclawUpdate}
412
+ updateInProgress=${openclawUpdateInProgress}
413
+ onActionComplete=${onOpenclawVersionActionComplete}
361
414
  />
362
415
  </div>
363
416
  </div>`;
364
- }
417
+ };
@@ -119,6 +119,19 @@ export const Image2FillIcon = ({ className = "" }) => html`
119
119
  </svg>
120
120
  `;
121
121
 
122
+ export const FileMusicLineIcon = ({ className = "" }) => html`
123
+ <svg
124
+ class=${className}
125
+ viewBox="0 0 24 24"
126
+ fill="currentColor"
127
+ aria-hidden="true"
128
+ >
129
+ <path
130
+ d="M16 8V10H13V14.5C13 15.8807 11.8807 17 10.5 17C9.11929 17 8 15.8807 8 14.5C8 13.1193 9.11929 12 10.5 12C10.6712 12 10.8384 12.0172 11 12.05V8H15V4H5V20H19V8H16ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"
131
+ />
132
+ </svg>
133
+ `;
134
+
122
135
  export const TerminalFillIcon = ({ className = "" }) => html`
123
136
  <svg
124
137
  class=${className}