@chrysb/alphaclaw 0.3.3 → 0.3.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/alphaclaw.js +18 -0
  2. package/lib/plugin/usage-tracker/index.js +308 -0
  3. package/lib/plugin/usage-tracker/openclaw.plugin.json +8 -0
  4. package/lib/public/css/explorer.css +51 -1
  5. package/lib/public/css/shell.css +3 -1
  6. package/lib/public/css/theme.css +35 -0
  7. package/lib/public/js/app.js +73 -24
  8. package/lib/public/js/components/file-tree.js +181 -6
  9. package/lib/public/js/components/file-viewer.js +43 -20
  10. package/lib/public/js/components/segmented-control.js +33 -0
  11. package/lib/public/js/components/sidebar.js +14 -32
  12. package/lib/public/js/components/telegram-workspace/index.js +353 -0
  13. package/lib/public/js/components/telegram-workspace/manage.js +397 -0
  14. package/lib/public/js/components/telegram-workspace/onboarding.js +616 -0
  15. package/lib/public/js/components/usage-tab.js +528 -0
  16. package/lib/public/js/components/watchdog-tab.js +1 -1
  17. package/lib/public/js/lib/api.js +25 -1
  18. package/lib/public/js/lib/telegram-api.js +78 -0
  19. package/lib/public/js/lib/ui-settings.js +38 -0
  20. package/lib/public/setup.html +34 -30
  21. package/lib/server/alphaclaw-version.js +3 -3
  22. package/lib/server/constants.js +1 -0
  23. package/lib/server/onboarding/openclaw.js +15 -0
  24. package/lib/server/routes/auth.js +5 -1
  25. package/lib/server/routes/telegram.js +185 -60
  26. package/lib/server/routes/usage.js +133 -0
  27. package/lib/server/usage-db.js +570 -0
  28. package/lib/server.js +21 -1
  29. package/lib/setup/core-prompts/AGENTS.md +0 -101
  30. package/package.json +1 -1
  31. package/lib/public/js/components/telegram-workspace.js +0 -1365
@@ -1,5 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { fetchBrowseTree } from "../lib/api.js";
5
5
  import {
@@ -48,6 +48,37 @@ const collectFolderPaths = (node, folderPaths) => {
48
48
  );
49
49
  };
50
50
 
51
+ const collectFilePaths = (node, filePaths) => {
52
+ if (!node) return;
53
+ if (node.type === "file") {
54
+ if (node.path) filePaths.push(node.path);
55
+ return;
56
+ }
57
+ (node.children || []).forEach((childNode) =>
58
+ collectFilePaths(childNode, filePaths),
59
+ );
60
+ };
61
+
62
+ const filterTreeNode = (node, normalizedQuery) => {
63
+ if (!node) return null;
64
+ const query = String(normalizedQuery || "").trim().toLowerCase();
65
+ if (!query) return node;
66
+ const nodeName = String(node.name || "").toLowerCase();
67
+ const nodePath = String(node.path || "").toLowerCase();
68
+ const isDirectMatch = nodeName.includes(query) || nodePath.includes(query);
69
+ if (node.type === "file") {
70
+ return isDirectMatch ? node : null;
71
+ }
72
+ const filteredChildren = (node.children || [])
73
+ .map((childNode) => filterTreeNode(childNode, query))
74
+ .filter(Boolean);
75
+ if (!isDirectMatch && filteredChildren.length === 0) return null;
76
+ return {
77
+ ...node,
78
+ children: filteredChildren,
79
+ };
80
+ };
81
+
51
82
  const getFileIconMeta = (fileName) => {
52
83
  const normalizedName = String(fileName || "").toLowerCase();
53
84
  if (normalizedName.endsWith(".md")) {
@@ -126,17 +157,20 @@ const TreeNode = ({
126
157
  onSelectFile,
127
158
  selectedPath = "",
128
159
  draftPaths,
160
+ isSearchActive = false,
161
+ searchActivePath = "",
129
162
  }) => {
130
163
  if (!node) return null;
131
164
  if (node.type === "file") {
132
165
  const isActive = selectedPath === node.path;
166
+ const isSearchActiveNode = searchActivePath === node.path;
133
167
  const hasDraft = draftPaths.has(node.path || "");
134
168
  const fileIconMeta = getFileIconMeta(node.name);
135
169
  const FileTypeIcon = fileIconMeta.icon;
136
170
  return html`
137
171
  <li class="tree-item">
138
172
  <a
139
- class=${isActive ? "active" : ""}
173
+ class=${`${isActive ? "active" : ""} ${isSearchActiveNode && !isActive ? "soft-active" : ""}`.trim()}
140
174
  onclick=${() => onSelectFile(node.path)}
141
175
  style=${{
142
176
  paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`,
@@ -152,7 +186,7 @@ const TreeNode = ({
152
186
  }
153
187
 
154
188
  const folderPath = node.path || "";
155
- const isCollapsed = collapsedPaths.has(folderPath);
189
+ const isCollapsed = isSearchActive ? false : collapsedPaths.has(folderPath);
156
190
  return html`
157
191
  <li class="tree-item">
158
192
  <div
@@ -178,6 +212,8 @@ const TreeNode = ({
178
212
  onSelectFile=${onSelectFile}
179
213
  selectedPath=${selectedPath}
180
214
  draftPaths=${draftPaths}
215
+ isSearchActive=${isSearchActive}
216
+ searchActivePath=${searchActivePath}
181
217
  />
182
218
  `,
183
219
  )}
@@ -186,7 +222,11 @@ const TreeNode = ({
186
222
  `;
187
223
  };
188
224
 
189
- export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
225
+ export const FileTree = ({
226
+ onSelectFile = () => {},
227
+ selectedPath = "",
228
+ onPreviewFile = () => {},
229
+ }) => {
190
230
  const [treeRoot, setTreeRoot] = useState(null);
191
231
  const [loading, setLoading] = useState(true);
192
232
  const [error, setError] = useState("");
@@ -194,6 +234,9 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
194
234
  readStoredCollapsedPaths,
195
235
  );
196
236
  const [draftPaths, setDraftPaths] = useState(readStoredDraftPaths);
237
+ const [searchQuery, setSearchQuery] = useState("");
238
+ const [searchActivePath, setSearchActivePath] = useState("");
239
+ const searchInputRef = useRef(null);
197
240
 
198
241
  useEffect(() => {
199
242
  let active = true;
@@ -223,9 +266,22 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
223
266
  };
224
267
  }, []);
225
268
 
226
- const rootChildren = useMemo(() => treeRoot?.children || [], [treeRoot]);
269
+ const normalizedSearchQuery = String(searchQuery || "").trim().toLowerCase();
270
+ const rootChildren = useMemo(() => {
271
+ const children = treeRoot?.children || [];
272
+ if (!normalizedSearchQuery) return children;
273
+ return children
274
+ .map((node) => filterTreeNode(node, normalizedSearchQuery))
275
+ .filter(Boolean);
276
+ }, [treeRoot, normalizedSearchQuery]);
227
277
  const safeCollapsedPaths =
228
278
  collapsedPaths instanceof Set ? collapsedPaths : new Set();
279
+ const isSearchActive = normalizedSearchQuery.length > 0;
280
+ const filteredFilePaths = useMemo(() => {
281
+ const filePaths = [];
282
+ rootChildren.forEach((node) => collectFilePaths(node, filePaths));
283
+ return filePaths;
284
+ }, [rootChildren]);
229
285
 
230
286
  useEffect(() => {
231
287
  if (!(collapsedPaths instanceof Set)) return;
@@ -278,6 +334,39 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
278
334
  };
279
335
  }, []);
280
336
 
337
+ useEffect(() => {
338
+ const handleGlobalSearchShortcut = (event) => {
339
+ if (event.key !== "/") return;
340
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
341
+ const target = event.target;
342
+ const tagName = String(target?.tagName || "").toLowerCase();
343
+ const isTypingTarget =
344
+ tagName === "input" ||
345
+ tagName === "textarea" ||
346
+ tagName === "select" ||
347
+ target?.isContentEditable;
348
+ if (isTypingTarget && target !== searchInputRef.current) return;
349
+ event.preventDefault();
350
+ searchInputRef.current?.focus();
351
+ searchInputRef.current?.select();
352
+ };
353
+ window.addEventListener("keydown", handleGlobalSearchShortcut);
354
+ return () => {
355
+ window.removeEventListener("keydown", handleGlobalSearchShortcut);
356
+ };
357
+ }, []);
358
+
359
+ useEffect(() => {
360
+ if (!isSearchActive) {
361
+ setSearchActivePath("");
362
+ onPreviewFile("");
363
+ return;
364
+ }
365
+ if (searchActivePath && filteredFilePaths.includes(searchActivePath)) return;
366
+ setSearchActivePath("");
367
+ onPreviewFile("");
368
+ }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]);
369
+
281
370
  const toggleFolder = (folderPath) => {
282
371
  setCollapsedPaths((previousPaths) => {
283
372
  const nextPaths =
@@ -288,6 +377,58 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
288
377
  });
289
378
  };
290
379
 
380
+ const updateSearchQuery = (nextQuery) => {
381
+ setSearchQuery(nextQuery);
382
+ };
383
+
384
+ const clearSearch = () => {
385
+ setSearchQuery("");
386
+ setSearchActivePath("");
387
+ onPreviewFile("");
388
+ };
389
+
390
+ const moveSearchSelection = (direction) => {
391
+ if (!filteredFilePaths.length) return;
392
+ const currentIndex = filteredFilePaths.indexOf(searchActivePath);
393
+ const delta = direction === "up" ? -1 : 1;
394
+ const baseIndex = currentIndex === -1 ? (direction === "up" ? 0 : -1) : currentIndex;
395
+ const nextIndex =
396
+ (baseIndex + delta + filteredFilePaths.length) % filteredFilePaths.length;
397
+ const nextPath = filteredFilePaths[nextIndex];
398
+ setSearchActivePath(nextPath);
399
+ onPreviewFile(nextPath);
400
+ };
401
+
402
+ const commitSearchSelection = () => {
403
+ const [singlePath = ""] = filteredFilePaths;
404
+ const targetPath = searchActivePath || (filteredFilePaths.length === 1 ? singlePath : "");
405
+ if (!targetPath) return;
406
+ onSelectFile(targetPath);
407
+ clearSearch();
408
+ };
409
+
410
+ const onSearchKeyDown = (event) => {
411
+ if (event.key === "ArrowDown") {
412
+ event.preventDefault();
413
+ moveSearchSelection("down");
414
+ return;
415
+ }
416
+ if (event.key === "ArrowUp") {
417
+ event.preventDefault();
418
+ moveSearchSelection("up");
419
+ return;
420
+ }
421
+ if (event.key === "Enter") {
422
+ event.preventDefault();
423
+ commitSearchSelection();
424
+ return;
425
+ }
426
+ if (event.key === "Escape") {
427
+ event.preventDefault();
428
+ clearSearch();
429
+ }
430
+ };
431
+
291
432
  if (loading) {
292
433
  return html`<div class="file-tree-state">Loading files...</div>`;
293
434
  }
@@ -297,11 +438,43 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
297
438
  </div>`;
298
439
  }
299
440
  if (!rootChildren.length) {
300
- return html`<div class="file-tree-state">No files found.</div>`;
441
+ return html`
442
+ <div class="file-tree-wrap">
443
+ <div class="file-tree-search">
444
+ <input
445
+ class="file-tree-search-input"
446
+ type="text"
447
+ ref=${searchInputRef}
448
+ value=${searchQuery}
449
+ onInput=${(event) => updateSearchQuery(event.target.value)}
450
+ onKeyDown=${onSearchKeyDown}
451
+ placeholder="Search files..."
452
+ autocomplete="off"
453
+ spellcheck=${false}
454
+ />
455
+ </div>
456
+ <div class="file-tree-state">
457
+ ${isSearchActive ? "No matching files." : "No files found."}
458
+ </div>
459
+ </div>
460
+ `;
301
461
  }
302
462
 
303
463
  return html`
304
464
  <div class="file-tree-wrap">
465
+ <div class="file-tree-search">
466
+ <input
467
+ class="file-tree-search-input"
468
+ type="text"
469
+ ref=${searchInputRef}
470
+ value=${searchQuery}
471
+ onInput=${(event) => updateSearchQuery(event.target.value)}
472
+ onKeyDown=${onSearchKeyDown}
473
+ placeholder="Search files..."
474
+ autocomplete="off"
475
+ spellcheck=${false}
476
+ />
477
+ </div>
305
478
  <ul class="file-tree">
306
479
  ${rootChildren.map(
307
480
  (node) => html`
@@ -313,6 +486,8 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
313
486
  onSelectFile=${onSelectFile}
314
487
  selectedPath=${selectedPath}
315
488
  draftPaths=${draftPaths}
489
+ isSearchActive=${isSearchActive}
490
+ searchActivePath=${searchActivePath}
316
491
  />
317
492
  `,
318
493
  )}
@@ -22,6 +22,8 @@ import {
22
22
  writeStoredFileDraft,
23
23
  } from "../lib/browse-draft-state.js";
24
24
  import { ActionButton } from "./action-button.js";
25
+ import { LoadingSpinner } from "./loading-spinner.js";
26
+ import { SegmentedControl } from "./segmented-control.js";
25
27
  import { SaveFillIcon } from "./icons.js";
26
28
  import { showToast } from "./toast.js";
27
29
 
@@ -29,6 +31,7 @@ const html = htm.bind(h);
29
31
  const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode";
30
32
  const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode";
31
33
  const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]);
34
+ const kLoadingIndicatorDelayMs = 1000;
32
35
 
33
36
  const parsePathSegments = (inputPath) =>
34
37
  String(inputPath || "")
@@ -58,13 +61,18 @@ const readStoredFileViewerMode = () => {
58
61
  };
59
62
 
60
63
 
61
- export const FileViewer = ({ filePath = "" }) => {
64
+ export const FileViewer = ({
65
+ filePath = "",
66
+ isPreviewOnly = false,
67
+ }) => {
62
68
  const normalizedPath = String(filePath || "").trim();
63
69
  const normalizedPolicyPath = normalizePolicyPath(normalizedPath);
64
70
  const [content, setContent] = useState("");
65
71
  const [initialContent, setInitialContent] = useState("");
66
72
  const [viewMode, setViewMode] = useState(readStoredFileViewerMode);
67
73
  const [loading, setLoading] = useState(false);
74
+ const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] =
75
+ useState(false);
68
76
  const [saving, setSaving] = useState(false);
69
77
  const [error, setError] = useState("");
70
78
  const [isFolderPath, setIsFolderPath] = useState(false);
@@ -87,7 +95,7 @@ export const FileViewer = ({ filePath = "" }) => {
87
95
  [normalizedPath],
88
96
  );
89
97
  const hasSelectedPath = normalizedPath.length > 0;
90
- const canEditFile = hasSelectedPath && !isFolderPath;
98
+ const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly;
91
99
  const isDirty = canEditFile && content !== initialContent;
92
100
  const isProtectedFile =
93
101
  canEditFile && kProtectedBrowsePaths.has(normalizedPolicyPath);
@@ -164,6 +172,17 @@ export const FileViewer = ({ filePath = "" }) => {
164
172
  } catch {}
165
173
  }, [viewMode]);
166
174
 
175
+ useEffect(() => {
176
+ if (!loading) {
177
+ setShowDelayedLoadingSpinner(false);
178
+ return () => {};
179
+ }
180
+ const timer = window.setTimeout(() => {
181
+ setShowDelayedLoadingSpinner(true);
182
+ }, kLoadingIndicatorDelayMs);
183
+ return () => window.clearTimeout(timer);
184
+ }, [loading]);
185
+
167
186
  useEffect(() => {
168
187
  let active = true;
169
188
  loadedFilePathRef.current = "";
@@ -282,7 +301,7 @@ export const FileViewer = ({ filePath = "" }) => {
282
301
  };
283
302
 
284
303
  const handleContentInput = (event) => {
285
- if (isProtectedLocked) return;
304
+ if (isProtectedLocked || isPreviewOnly) return;
286
305
  const nextContent = event.target.value;
287
306
  setContent(nextContent);
288
307
  if (hasSelectedPath && canEditFile) {
@@ -408,22 +427,20 @@ export const FileViewer = ({ filePath = "" }) => {
408
427
  : null}
409
428
  </div>
410
429
  <div class="file-viewer-tabbar-spacer"></div>
430
+ ${isPreviewOnly
431
+ ? html`<div class="file-viewer-preview-pill">Preview</div>`
432
+ : null}
411
433
  ${isMarkdownFile &&
412
434
  html`
413
- <div class="file-viewer-view-toggle">
414
- <button
415
- class=${`file-viewer-view-toggle-button ${viewMode === "edit" ? "active" : ""}`}
416
- onclick=${() => handleChangeViewMode("edit")}
417
- >
418
- edit
419
- </button>
420
- <button
421
- class=${`file-viewer-view-toggle-button ${viewMode === "preview" ? "active" : ""}`}
422
- onclick=${() => handleChangeViewMode("preview")}
423
- >
424
- preview
425
- </button>
426
- </div>
435
+ <${SegmentedControl}
436
+ className="mr-2.5"
437
+ options=${[
438
+ { label: "edit", value: "edit" },
439
+ { label: "preview", value: "preview" },
440
+ ]}
441
+ value=${viewMode}
442
+ onChange=${handleChangeViewMode}
443
+ />
427
444
  `}
428
445
  <${ActionButton}
429
446
  onClick=${handleSave}
@@ -508,7 +525,13 @@ ${formattedValue}</pre
508
525
  `
509
526
  : null}
510
527
  ${loading
511
- ? html`<div class="file-viewer-state">Loading file...</div>`
528
+ ? html`
529
+ <div class="file-viewer-loading-shell">
530
+ ${showDelayedLoadingSpinner
531
+ ? html`<${LoadingSpinner} className="h-4 w-4" />`
532
+ : null}
533
+ </div>
534
+ `
512
535
  : error
513
536
  ? html`<div class="file-viewer-state file-viewer-state-error">
514
537
  ${error}
@@ -653,7 +676,7 @@ ${formattedValue}</pre
653
676
  value=${content}
654
677
  onInput=${handleContentInput}
655
678
  onScroll=${handleEditorScroll}
656
- readonly=${isProtectedLocked}
679
+ readonly=${isProtectedLocked || isPreviewOnly}
657
680
  spellcheck=${false}
658
681
  autocorrect="off"
659
682
  autocapitalize="off"
@@ -672,7 +695,7 @@ ${formattedValue}</pre
672
695
  value=${content}
673
696
  onInput=${handleContentInput}
674
697
  onScroll=${handleEditorScroll}
675
- readonly=${isProtectedLocked}
698
+ readonly=${isProtectedLocked || isPreviewOnly}
676
699
  spellcheck=${false}
677
700
  autocorrect="off"
678
701
  autocapitalize="off"
@@ -0,0 +1,33 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ /**
7
+ * Reusable segmented control (pill toggle).
8
+ *
9
+ * @param {Object} props
10
+ * @param {Array<{label:string, value:*}>} props.options
11
+ * @param {*} props.value Currently selected value.
12
+ * @param {Function} props.onChange Called with the new value on click.
13
+ * @param {string} [props.className] Extra classes on the wrapper.
14
+ */
15
+ export const SegmentedControl = ({
16
+ options = [],
17
+ value,
18
+ onChange = () => {},
19
+ className = "",
20
+ }) => html`
21
+ <div class=${`ac-segmented-control ${className}`}>
22
+ ${options.map(
23
+ (option) => html`
24
+ <button
25
+ class=${`ac-segmented-control-button ${option.value === value ? "active" : ""}`}
26
+ onclick=${() => onChange(option.value)}
27
+ >
28
+ ${option.label}
29
+ </button>
30
+ `,
31
+ )}
32
+ </div>
33
+ `;
@@ -5,37 +5,26 @@ import { HomeLineIcon, FolderLineIcon } from "./icons.js";
5
5
  import { FileTree } from "./file-tree.js";
6
6
  import { UpdateActionButton } from "./update-action-button.js";
7
7
  import { SidebarGitPanel } from "./sidebar-git-panel.js";
8
+ import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
8
9
 
9
10
  const html = htm.bind(h);
10
- const kUiSettingsStorageKey = "alphaclaw.uiSettings";
11
- const kLegacyUiSettingsStorageKey = "alphaclawUiSettings";
12
11
  const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx";
13
12
  const kBrowsePanelMinHeightPx = 120;
14
13
  const kBrowseBottomMinHeightPx = 120;
15
14
  const kBrowseResizerHeightPx = 6;
16
15
  const kDefaultBrowseBottomPanelHeightPx = 160;
17
- const kLegacyBrowsePanelUiStorageKey = "alphaclawBrowsePanelHeightPx";
18
16
 
19
17
  const readStoredBrowseBottomPanelHeight = () => {
20
18
  try {
21
- const rawSettings =
22
- window.localStorage.getItem(kUiSettingsStorageKey) ||
23
- window.localStorage.getItem(kLegacyUiSettingsStorageKey);
24
- if (rawSettings) {
25
- const parsedSettings = JSON.parse(rawSettings);
26
- const fromSharedSettings = Number.parseInt(
27
- String(parsedSettings?.[kBrowseBottomPanelUiSettingKey] || ""),
28
- 10,
29
- );
30
- if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) {
31
- return fromSharedSettings;
32
- }
19
+ const settings = readUiSettings();
20
+ const fromSharedSettings = Number.parseInt(
21
+ String(settings?.[kBrowseBottomPanelUiSettingKey] || ""),
22
+ 10,
23
+ );
24
+ if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) {
25
+ return fromSharedSettings;
33
26
  }
34
- const legacyRawValue = window.localStorage.getItem(kLegacyBrowsePanelUiStorageKey);
35
- const parsedValue = Number.parseInt(String(legacyRawValue || ""), 10);
36
- return Number.isFinite(parsedValue) && parsedValue > 0
37
- ? parsedValue
38
- : kDefaultBrowseBottomPanelHeightPx;
27
+ return kDefaultBrowseBottomPanelHeightPx;
39
28
  } catch {
40
29
  return kDefaultBrowseBottomPanelHeightPx;
41
30
  }
@@ -55,6 +44,7 @@ export const AppSidebar = ({
55
44
  onSelectNavItem = () => {},
56
45
  selectedBrowsePath = "",
57
46
  onSelectBrowseFile = () => {},
47
+ onPreviewBrowseFile = () => {},
58
48
  acHasUpdate = false,
59
49
  acLatest = "",
60
50
  acDismissed = false,
@@ -70,18 +60,9 @@ export const AppSidebar = ({
70
60
  const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false);
71
61
 
72
62
  useEffect(() => {
73
- try {
74
- const rawSettings =
75
- window.localStorage.getItem(kUiSettingsStorageKey) ||
76
- window.localStorage.getItem(kLegacyUiSettingsStorageKey);
77
- const parsedSettings = rawSettings ? JSON.parse(rawSettings) : {};
78
- const nextSettings =
79
- parsedSettings && typeof parsedSettings === "object"
80
- ? { ...parsedSettings }
81
- : {};
82
- nextSettings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx;
83
- window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(nextSettings));
84
- } catch {}
63
+ const settings = readUiSettings();
64
+ settings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx;
65
+ writeUiSettings(settings);
85
66
  }, [browseBottomPanelHeightPx]);
86
67
 
87
68
  const getClampedBrowseBottomPanelHeight = (value) => {
@@ -217,6 +198,7 @@ export const AppSidebar = ({
217
198
  <${FileTree}
218
199
  onSelectFile=${onSelectBrowseFile}
219
200
  selectedPath=${selectedBrowsePath}
201
+ onPreviewFile=${onPreviewBrowseFile}
220
202
  />
221
203
  </div>
222
204
  <div