@chrysb/alphaclaw 0.3.5-beta.0 → 0.4.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.
Files changed (64) hide show
  1. package/bin/alphaclaw.js +66 -32
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +254 -6
  4. package/lib/public/js/app.js +165 -100
  5. package/lib/public/js/components/channels.js +1 -0
  6. package/lib/public/js/components/credentials-modal.js +36 -8
  7. package/lib/public/js/components/file-tree.js +267 -88
  8. package/lib/public/js/components/file-viewer/constants.js +6 -0
  9. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  10. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  11. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  12. package/lib/public/js/components/file-viewer/index.js +202 -0
  13. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  14. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  15. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  16. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  17. package/lib/public/js/components/file-viewer/status-banners.js +64 -0
  18. package/lib/public/js/components/file-viewer/storage.js +58 -0
  19. package/lib/public/js/components/file-viewer/toolbar.js +119 -0
  20. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +93 -0
  21. package/lib/public/js/components/file-viewer/use-file-diff.js +60 -0
  22. package/lib/public/js/components/file-viewer/use-file-loader.js +312 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  24. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  25. package/lib/public/js/components/file-viewer/use-file-viewer.js +471 -0
  26. package/lib/public/js/components/file-viewer/utils.js +11 -0
  27. package/lib/public/js/components/gateway.js +83 -30
  28. package/lib/public/js/components/google/account-row.js +98 -0
  29. package/lib/public/js/components/google/add-account-modal.js +93 -0
  30. package/lib/public/js/components/google/index.js +439 -0
  31. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  32. package/lib/public/js/components/icons.js +39 -0
  33. package/lib/public/js/components/sidebar-git-panel.js +115 -25
  34. package/lib/public/js/components/sidebar.js +91 -75
  35. package/lib/public/js/components/usage-tab.js +4 -1
  36. package/lib/public/js/components/watchdog-tab.js +6 -0
  37. package/lib/public/js/lib/api.js +88 -8
  38. package/lib/public/js/lib/browse-file-policies.js +52 -0
  39. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  40. package/lib/public/shared/browse-file-policies.json +13 -0
  41. package/lib/scripts/git +40 -0
  42. package/lib/scripts/git-askpass +6 -0
  43. package/lib/server/constants.js +20 -0
  44. package/lib/server/google-state.js +187 -0
  45. package/lib/server/helpers.js +12 -4
  46. package/lib/server/onboarding/github.js +21 -2
  47. package/lib/server/onboarding/index.js +1 -3
  48. package/lib/server/onboarding/openclaw.js +3 -0
  49. package/lib/server/onboarding/workspace.js +40 -0
  50. package/lib/server/routes/browse/constants.js +51 -0
  51. package/lib/server/routes/browse/file-helpers.js +43 -0
  52. package/lib/server/routes/browse/git.js +131 -0
  53. package/lib/server/routes/browse/index.js +660 -0
  54. package/lib/server/routes/browse/path-utils.js +53 -0
  55. package/lib/server/routes/browse/sqlite.js +140 -0
  56. package/lib/server/routes/google.js +414 -213
  57. package/lib/server/routes/proxy.js +11 -5
  58. package/lib/setup/core-prompts/TOOLS.md +0 -4
  59. package/lib/setup/gitignore +3 -0
  60. package/lib/setup/hourly-git-sync.sh +28 -1
  61. package/package.json +1 -1
  62. package/lib/public/js/components/file-viewer.js +0 -1095
  63. package/lib/public/js/components/google.js +0 -228
  64. package/lib/server/routes/browse.js +0 -500
@@ -0,0 +1,471 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
2
+ import { marked } from "https://esm.sh/marked";
3
+ import { deleteBrowseFile, restoreBrowseFile, 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
+ onRequestClearSelection = () => {},
36
+ onRequestEdit = () => {},
37
+ }) => {
38
+ const normalizedPath = String(filePath || "").trim();
39
+ const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath);
40
+ const [content, setContent] = useState("");
41
+ const [initialContent, setInitialContent] = useState("");
42
+ const [fileKind, setFileKind] = useState("text");
43
+ const [imageDataUrl, setImageDataUrl] = useState("");
44
+ const [audioDataUrl, setAudioDataUrl] = useState("");
45
+ const [sqliteSummary, setSqliteSummary] = useState(null);
46
+ const [sqliteSelectedTable, setSqliteSelectedTable] = useState("");
47
+ const [sqliteTableOffset, setSqliteTableOffset] = useState(0);
48
+ const [sqliteTableLoading, setSqliteTableLoading] = useState(false);
49
+ const [sqliteTableError, setSqliteTableError] = useState("");
50
+ const [sqliteTableData, setSqliteTableData] = useState(null);
51
+ const [viewMode, setViewMode] = useState(readStoredFileViewerMode);
52
+ const [loading, setLoading] = useState(false);
53
+ const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false);
54
+ const [saving, setSaving] = useState(false);
55
+ const [deleting, setDeleting] = useState(false);
56
+ const [restoring, setRestoring] = useState(false);
57
+ const [error, setError] = useState("");
58
+ const [isFolderPath, setIsFolderPath] = useState(false);
59
+ const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false);
60
+ const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false);
61
+ const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set());
62
+ const editorLineNumbersRef = useRef(null);
63
+ const editorHighlightRef = useRef(null);
64
+ const editorTextareaRef = useRef(null);
65
+ const previewRef = useRef(null);
66
+ const editorLineNumberRowRefs = useRef([]);
67
+ const editorHighlightLineRefs = useRef([]);
68
+
69
+ const hasSelectedPath = normalizedPath.length > 0;
70
+ const isImageFile = fileKind === "image";
71
+ const isAudioFile = fileKind === "audio";
72
+ const isSqliteFile = fileKind === "sqlite";
73
+ const canEditFile =
74
+ hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile;
75
+ const isDiffView = String(browseView || "edit") === "diff";
76
+
77
+ const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } =
78
+ useScrollSync({
79
+ viewMode,
80
+ setViewMode,
81
+ previewRef,
82
+ editorTextareaRef,
83
+ editorLineNumbersRef,
84
+ editorHighlightRef,
85
+ });
86
+
87
+ const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({
88
+ hasSelectedPath,
89
+ normalizedPath,
90
+ isDiffView,
91
+ isSqliteFile,
92
+ sqliteSelectedTable,
93
+ sqliteTableOffset,
94
+ canEditFile,
95
+ isFolderPath,
96
+ loading,
97
+ saving,
98
+ initialContent,
99
+ isDirty: canEditFile && content !== initialContent,
100
+ setLoading,
101
+ setContent,
102
+ setInitialContent,
103
+ setFileKind,
104
+ setImageDataUrl,
105
+ setAudioDataUrl,
106
+ setSqliteSummary,
107
+ setSqliteSelectedTable,
108
+ setSqliteTableOffset,
109
+ setSqliteTableLoading,
110
+ setSqliteTableError,
111
+ setSqliteTableData,
112
+ setError,
113
+ setIsFolderPath,
114
+ setExternalChangeNoticeShown,
115
+ externalChangeNoticeShown,
116
+ viewScrollRatioRef,
117
+ });
118
+
119
+ const { diffLoading, diffError, diffContent, diffStatus } = useFileDiff({
120
+ hasSelectedPath,
121
+ isDiffView,
122
+ isPreviewOnly,
123
+ normalizedPath,
124
+ });
125
+
126
+ const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]);
127
+ const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath;
128
+ const renderContent = isCurrentFileLoaded ? content : "";
129
+ const renderInitialContent = isCurrentFileLoaded ? initialContent : "";
130
+ const isDirty = canEditFile && renderContent !== renderInitialContent;
131
+ const isLockedFile =
132
+ canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath);
133
+ const isProtectedFile =
134
+ canEditFile &&
135
+ !isLockedFile &&
136
+ matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath);
137
+ const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath);
138
+ const isEditBlocked = isLockedFile || isProtectedLocked;
139
+ const isDeleteBlocked = isLockedFile || isProtectedFile;
140
+ const canDeleteFile =
141
+ hasSelectedPath &&
142
+ !isFolderPath &&
143
+ !isPreviewOnly &&
144
+ !isDiffView &&
145
+ !deleting &&
146
+ !saving &&
147
+ !isDeleteBlocked;
148
+ const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]);
149
+ const isMarkdownFile = syntaxKind === "markdown";
150
+ const shouldUseHighlightedEditor = syntaxKind !== "plain";
151
+ const parsedFrontmatter = useMemo(
152
+ () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }),
153
+ [renderContent, isMarkdownFile],
154
+ );
155
+ const highlightedEditorLines = useMemo(
156
+ () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []),
157
+ [renderContent, shouldUseHighlightedEditor, syntaxKind],
158
+ );
159
+ const editorLineNumbers = useMemo(() => {
160
+ const lineCount = String(renderContent || "").split("\n").length;
161
+ return Array.from({ length: lineCount }, (_, index) => index + 1);
162
+ }, [renderContent]);
163
+ const previewHtml = useMemo(
164
+ () =>
165
+ isMarkdownFile
166
+ ? marked.parse(parsedFrontmatter.body || "", {
167
+ gfm: true,
168
+ breaks: true,
169
+ })
170
+ : "",
171
+ [parsedFrontmatter.body, isMarkdownFile],
172
+ );
173
+
174
+ const syncEditorLineNumberHeights = useCallback(() => {
175
+ if (!shouldUseHighlightedEditor || viewMode !== "edit") return;
176
+ const numberRows = editorLineNumberRowRefs.current;
177
+ const highlightRows = editorHighlightLineRefs.current;
178
+ const rowCount = Math.min(numberRows.length, highlightRows.length);
179
+ for (let index = 0; index < rowCount; index += 1) {
180
+ const numberRow = numberRows[index];
181
+ const highlightRow = highlightRows[index];
182
+ if (!numberRow || !highlightRow) continue;
183
+ numberRow.style.height = `${highlightRow.offsetHeight}px`;
184
+ }
185
+ }, [shouldUseHighlightedEditor, viewMode]);
186
+
187
+ useEffect(() => {
188
+ syncEditorLineNumberHeights();
189
+ }, [content, syncEditorLineNumberHeights]);
190
+
191
+ useEffect(() => {
192
+ if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {};
193
+ const onResize = () => syncEditorLineNumberHeights();
194
+ window.addEventListener("resize", onResize);
195
+ return () => window.removeEventListener("resize", onResize);
196
+ }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]);
197
+
198
+ useEffect(() => {
199
+ if (!isMarkdownFile && viewMode !== "edit") {
200
+ setViewMode("edit");
201
+ }
202
+ }, [isMarkdownFile, viewMode]);
203
+
204
+ useEffect(() => {
205
+ setProtectedEditBypassPaths(new Set());
206
+ }, [normalizedPath]);
207
+
208
+ useEffect(() => {
209
+ try {
210
+ window.localStorage.setItem(kFileViewerModeStorageKey, viewMode);
211
+ } catch {}
212
+ }, [viewMode]);
213
+
214
+ useEffect(() => {
215
+ if (!loading) {
216
+ setShowDelayedLoadingSpinner(false);
217
+ return () => {};
218
+ }
219
+ const timer = window.setTimeout(() => {
220
+ setShowDelayedLoadingSpinner(true);
221
+ }, kLoadingIndicatorDelayMs);
222
+ return () => window.clearTimeout(timer);
223
+ }, [loading]);
224
+
225
+ useFileViewerDraftSync({
226
+ loadedFilePathRef,
227
+ normalizedPath,
228
+ canEditFile,
229
+ hasSelectedPath,
230
+ loading,
231
+ content,
232
+ initialContent,
233
+ });
234
+
235
+ useEditorSelectionRestore({
236
+ canEditFile,
237
+ isEditBlocked,
238
+ loading,
239
+ hasSelectedPath,
240
+ normalizedPath,
241
+ loadedFilePathRef,
242
+ restoredSelectionPathRef,
243
+ viewMode,
244
+ content,
245
+ editorTextareaRef,
246
+ editorLineNumbersRef,
247
+ editorHighlightRef,
248
+ viewScrollRatioRef,
249
+ });
250
+
251
+ const handleSave = useCallback(async () => {
252
+ if (!canEditFile || saving || !isDirty || isEditBlocked) return;
253
+ setSaving(true);
254
+ setError("");
255
+ try {
256
+ await saveFileContent(normalizedPath, content);
257
+ setInitialContent(content);
258
+ setExternalChangeNoticeShown(false);
259
+ clearStoredFileDraft(normalizedPath);
260
+ updateDraftIndex(normalizedPath, false, {
261
+ dispatchEvent: (event) => window.dispatchEvent(event),
262
+ });
263
+ window.dispatchEvent(
264
+ new CustomEvent("alphaclaw:browse-file-saved", {
265
+ detail: { path: normalizedPath },
266
+ }),
267
+ );
268
+ showToast("Saved", "success");
269
+ } catch (saveError) {
270
+ const message = saveError.message || "Could not save file";
271
+ setError(message);
272
+ showToast(message, "error");
273
+ } finally {
274
+ setSaving(false);
275
+ }
276
+ }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]);
277
+
278
+ const handleDelete = useCallback(async () => {
279
+ if (!canDeleteFile) return;
280
+ setDeleting(true);
281
+ setError("");
282
+ try {
283
+ const data = await deleteBrowseFile(normalizedPath);
284
+ const deletedPath = String(data?.path || normalizedPath);
285
+ setExternalChangeNoticeShown(false);
286
+ clearStoredFileDraft(normalizedPath);
287
+ updateDraftIndex(normalizedPath, false, {
288
+ dispatchEvent: (event) => window.dispatchEvent(event),
289
+ });
290
+ window.dispatchEvent(
291
+ new CustomEvent("alphaclaw:browse-file-saved", {
292
+ detail: { path: deletedPath },
293
+ }),
294
+ );
295
+ window.dispatchEvent(
296
+ new CustomEvent("alphaclaw:browse-file-deleted", {
297
+ detail: { path: deletedPath },
298
+ }),
299
+ );
300
+ window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));
301
+ showToast("File deleted", "success");
302
+ onRequestClearSelection();
303
+ } catch (deleteError) {
304
+ const message = deleteError.message || "Could not delete file";
305
+ setError(message);
306
+ if (/path is not a file/i.test(message)) {
307
+ showToast("Only files can be deleted", "warning");
308
+ onRequestClearSelection();
309
+ } else {
310
+ showToast(message, "error");
311
+ }
312
+ } finally {
313
+ setDeleting(false);
314
+ }
315
+ }, [canDeleteFile, normalizedPath, onRequestClearSelection]);
316
+
317
+ const handleRestore = useCallback(async () => {
318
+ if (!isDiffView || !diffStatus?.isDeleted || restoring) return;
319
+ setRestoring(true);
320
+ try {
321
+ const data = await restoreBrowseFile(normalizedPath);
322
+ const restoredPath = String(data?.path || normalizedPath);
323
+ window.dispatchEvent(
324
+ new CustomEvent("alphaclaw:browse-file-saved", {
325
+ detail: { path: restoredPath },
326
+ }),
327
+ );
328
+ window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));
329
+ showToast("File restored", "success");
330
+ onRequestEdit(restoredPath);
331
+ } catch (restoreError) {
332
+ showToast(restoreError.message || "Could not restore file", "error");
333
+ } finally {
334
+ setRestoring(false);
335
+ }
336
+ }, [
337
+ diffStatus?.isDeleted,
338
+ isDiffView,
339
+ normalizedPath,
340
+ onRequestEdit,
341
+ restoring,
342
+ ]);
343
+
344
+ useFileViewerHotkeys({
345
+ canEditFile,
346
+ isPreviewOnly,
347
+ isDiffView,
348
+ viewMode,
349
+ handleSave,
350
+ });
351
+
352
+ const handleEditProtectedFile = () => {
353
+ if (!normalizedPolicyPath) return;
354
+ setProtectedEditBypassPaths((previousPaths) => {
355
+ const nextPaths = new Set(previousPaths);
356
+ nextPaths.add(normalizedPolicyPath);
357
+ return nextPaths;
358
+ });
359
+ window.requestAnimationFrame(() => {
360
+ window.requestAnimationFrame(() => {
361
+ const textareaElement = editorTextareaRef.current;
362
+ if (!textareaElement) return;
363
+ if (textareaElement.disabled || textareaElement.readOnly) return;
364
+ textareaElement.focus();
365
+ });
366
+ });
367
+ };
368
+
369
+ const handleContentInput = (event) => {
370
+ if (isEditBlocked || isPreviewOnly) return;
371
+ const nextContent = event.target.value;
372
+ setContent(nextContent);
373
+ if (hasSelectedPath && canEditFile) {
374
+ writeStoredEditorSelection(normalizedPath, {
375
+ start: event.target.selectionStart,
376
+ end: event.target.selectionEnd,
377
+ });
378
+ }
379
+ if (hasSelectedPath && canEditFile) {
380
+ writeStoredFileDraft(normalizedPath, nextContent);
381
+ updateDraftIndex(normalizedPath, nextContent !== initialContent, {
382
+ dispatchEvent: (event) => window.dispatchEvent(event),
383
+ });
384
+ }
385
+ };
386
+
387
+ const handleEditorSelectionChange = () => {
388
+ if (!hasSelectedPath || !canEditFile || loading) return;
389
+ const textareaElement = editorTextareaRef.current;
390
+ if (!textareaElement) return;
391
+ writeStoredEditorSelection(normalizedPath, {
392
+ start: textareaElement.selectionStart,
393
+ end: textareaElement.selectionEnd,
394
+ });
395
+ };
396
+
397
+ return {
398
+ state: {
399
+ hasSelectedPath,
400
+ isPreviewOnly,
401
+ loading,
402
+ saving,
403
+ deleting,
404
+ restoring,
405
+ showDelayedLoadingSpinner,
406
+ error,
407
+ isFolderPath,
408
+ isImageFile,
409
+ imageDataUrl,
410
+ isAudioFile,
411
+ audioDataUrl,
412
+ isSqliteFile,
413
+ sqliteSummary,
414
+ sqliteSelectedTable,
415
+ sqliteTableOffset,
416
+ sqliteTableLoading,
417
+ sqliteTableError,
418
+ sqliteTableData,
419
+ isDiffView,
420
+ diffLoading,
421
+ diffError,
422
+ diffContent,
423
+ diffStatus,
424
+ isMarkdownFile,
425
+ frontmatterCollapsed,
426
+ previewHtml,
427
+ viewMode,
428
+ renderContent,
429
+ },
430
+ derived: {
431
+ pathSegments,
432
+ isDirty,
433
+ canEditFile,
434
+ canDeleteFile,
435
+ isDeleteBlocked,
436
+ isEditBlocked,
437
+ isLockedFile,
438
+ isProtectedFile,
439
+ isProtectedLocked,
440
+ shouldUseHighlightedEditor,
441
+ parsedFrontmatter,
442
+ highlightedEditorLines,
443
+ editorLineNumbers,
444
+ },
445
+ refs: {
446
+ previewRef,
447
+ editorLineNumbersRef,
448
+ editorLineNumberRowRefs,
449
+ editorHighlightRef,
450
+ editorHighlightLineRefs,
451
+ editorTextareaRef,
452
+ },
453
+ actions: {
454
+ setFrontmatterCollapsed,
455
+ setSqliteSelectedTable,
456
+ setSqliteTableOffset,
457
+ handleChangeViewMode,
458
+ handleSave,
459
+ handleDelete,
460
+ handleRestore,
461
+ handleEditProtectedFile,
462
+ handleContentInput,
463
+ handleEditorScroll,
464
+ handlePreviewScroll,
465
+ handleEditorSelectionChange,
466
+ },
467
+ context: {
468
+ normalizedPath,
469
+ },
470
+ };
471
+ };
@@ -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
+ };