@chrysb/alphaclaw 0.3.3 → 0.3.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.
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 +231 -28
  9. package/lib/public/js/components/file-viewer.js +193 -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
package/bin/alphaclaw.js CHANGED
@@ -11,6 +11,14 @@ const {
11
11
  } = require("../lib/cli/git-sync");
12
12
  const { buildSecretReplacements } = require("../lib/server/helpers");
13
13
 
14
+ const kUsageTrackerPluginPath = path.resolve(
15
+ __dirname,
16
+ "..",
17
+ "lib",
18
+ "plugin",
19
+ "usage-tracker",
20
+ );
21
+
14
22
  // ---------------------------------------------------------------------------
15
23
  // Parse CLI flags
16
24
  // ---------------------------------------------------------------------------
@@ -772,6 +780,8 @@ if (fs.existsSync(configPath)) {
772
780
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
773
781
  if (!cfg.channels) cfg.channels = {};
774
782
  if (!cfg.plugins) cfg.plugins = {};
783
+ if (!cfg.plugins.load) cfg.plugins.load = {};
784
+ if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = [];
775
785
  if (!cfg.plugins.entries) cfg.plugins.entries = {};
776
786
  let changed = false;
777
787
 
@@ -798,6 +808,14 @@ if (fs.existsSync(configPath)) {
798
808
  console.log("[alphaclaw] Discord added");
799
809
  changed = true;
800
810
  }
811
+ if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) {
812
+ cfg.plugins.load.paths.push(kUsageTrackerPluginPath);
813
+ changed = true;
814
+ }
815
+ if (cfg.plugins.entries["usage-tracker"]?.enabled !== true) {
816
+ cfg.plugins.entries["usage-tracker"] = { enabled: true };
817
+ changed = true;
818
+ }
801
819
 
802
820
  if (changed) {
803
821
  let content = JSON.stringify(cfg, null, 2);
@@ -0,0 +1,308 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { DatabaseSync } = require("node:sqlite");
5
+
6
+ const kPluginId = "usage-tracker";
7
+ const kFallbackRootDir = path.join(os.homedir(), ".alphaclaw");
8
+
9
+ const coerceCount = (value) => {
10
+ const parsed = Number.parseInt(String(value ?? 0), 10);
11
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
12
+ };
13
+
14
+ const resolveRootDir = () =>
15
+ process.env.ALPHACLAW_ROOT_DIR ||
16
+ process.env.OPENCLAW_HOME ||
17
+ process.env.OPENCLAW_ROOT_DIR ||
18
+ kFallbackRootDir;
19
+
20
+ const safeAlterTable = (database, sql) => {
21
+ try {
22
+ database.exec(sql);
23
+ } catch (err) {
24
+ const message = String(err?.message || "").toLowerCase();
25
+ if (!message.includes("duplicate column name")) throw err;
26
+ }
27
+ };
28
+
29
+ const ensureSchema = (database) => {
30
+ database.exec("PRAGMA journal_mode=WAL;");
31
+ database.exec("PRAGMA synchronous=NORMAL;");
32
+ database.exec("PRAGMA busy_timeout=5000;");
33
+ database.exec(`
34
+ CREATE TABLE IF NOT EXISTS usage_events (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ timestamp INTEGER NOT NULL,
37
+ session_id TEXT,
38
+ session_key TEXT,
39
+ run_id TEXT,
40
+ provider TEXT NOT NULL,
41
+ model TEXT NOT NULL,
42
+ input_tokens INTEGER NOT NULL DEFAULT 0,
43
+ output_tokens INTEGER NOT NULL DEFAULT 0,
44
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
45
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
46
+ total_tokens INTEGER NOT NULL DEFAULT 0
47
+ );
48
+ `);
49
+ database.exec(`
50
+ CREATE INDEX IF NOT EXISTS idx_usage_events_ts
51
+ ON usage_events(timestamp DESC);
52
+ `);
53
+ database.exec(`
54
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session
55
+ ON usage_events(session_id);
56
+ `);
57
+ safeAlterTable(
58
+ database,
59
+ "ALTER TABLE usage_events ADD COLUMN session_key TEXT;",
60
+ );
61
+ database.exec(`
62
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session_key
63
+ ON usage_events(session_key);
64
+ `);
65
+ database.exec(`
66
+ CREATE TABLE IF NOT EXISTS usage_daily (
67
+ date TEXT NOT NULL,
68
+ model TEXT NOT NULL,
69
+ provider TEXT,
70
+ input_tokens INTEGER NOT NULL DEFAULT 0,
71
+ output_tokens INTEGER NOT NULL DEFAULT 0,
72
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
73
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
74
+ total_tokens INTEGER NOT NULL DEFAULT 0,
75
+ turn_count INTEGER NOT NULL DEFAULT 0,
76
+ PRIMARY KEY (date, model)
77
+ );
78
+ `);
79
+ database.exec(`
80
+ CREATE TABLE IF NOT EXISTS tool_events (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ timestamp INTEGER NOT NULL,
83
+ session_id TEXT,
84
+ session_key TEXT,
85
+ tool_name TEXT NOT NULL,
86
+ success INTEGER NOT NULL DEFAULT 1,
87
+ duration_ms INTEGER
88
+ );
89
+ `);
90
+ database.exec(`
91
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session
92
+ ON tool_events(session_id);
93
+ `);
94
+ safeAlterTable(
95
+ database,
96
+ "ALTER TABLE tool_events ADD COLUMN session_key TEXT;",
97
+ );
98
+ database.exec(`
99
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session_key
100
+ ON tool_events(session_key);
101
+ `);
102
+ };
103
+
104
+ const createPlugin = () => {
105
+ let database = null;
106
+ let dbPath = "";
107
+ let insertUsageEventStmt = null;
108
+ let upsertUsageDailyStmt = null;
109
+ let insertToolEventStmt = null;
110
+
111
+ const getDatabase = () => {
112
+ if (database) return database;
113
+ const rootDir = resolveRootDir();
114
+ const dbDir = path.join(rootDir, "db");
115
+ fs.mkdirSync(dbDir, { recursive: true });
116
+ dbPath = path.join(dbDir, "usage.db");
117
+ database = new DatabaseSync(dbPath);
118
+ ensureSchema(database);
119
+ insertUsageEventStmt = database.prepare(`
120
+ INSERT INTO usage_events (
121
+ timestamp,
122
+ session_id,
123
+ session_key,
124
+ run_id,
125
+ provider,
126
+ model,
127
+ input_tokens,
128
+ output_tokens,
129
+ cache_read_tokens,
130
+ cache_write_tokens,
131
+ total_tokens
132
+ ) VALUES (
133
+ $timestamp,
134
+ $session_id,
135
+ $session_key,
136
+ $run_id,
137
+ $provider,
138
+ $model,
139
+ $input_tokens,
140
+ $output_tokens,
141
+ $cache_read_tokens,
142
+ $cache_write_tokens,
143
+ $total_tokens
144
+ )
145
+ `);
146
+ upsertUsageDailyStmt = database.prepare(`
147
+ INSERT INTO usage_daily (
148
+ date,
149
+ model,
150
+ provider,
151
+ input_tokens,
152
+ output_tokens,
153
+ cache_read_tokens,
154
+ cache_write_tokens,
155
+ total_tokens,
156
+ turn_count
157
+ ) VALUES (
158
+ $date,
159
+ $model,
160
+ $provider,
161
+ $input_tokens,
162
+ $output_tokens,
163
+ $cache_read_tokens,
164
+ $cache_write_tokens,
165
+ $total_tokens,
166
+ 1
167
+ )
168
+ ON CONFLICT(date, model) DO UPDATE SET
169
+ provider = COALESCE(excluded.provider, usage_daily.provider),
170
+ input_tokens = usage_daily.input_tokens + excluded.input_tokens,
171
+ output_tokens = usage_daily.output_tokens + excluded.output_tokens,
172
+ cache_read_tokens = usage_daily.cache_read_tokens + excluded.cache_read_tokens,
173
+ cache_write_tokens = usage_daily.cache_write_tokens + excluded.cache_write_tokens,
174
+ total_tokens = usage_daily.total_tokens + excluded.total_tokens,
175
+ turn_count = usage_daily.turn_count + 1
176
+ `);
177
+ insertToolEventStmt = database.prepare(`
178
+ INSERT INTO tool_events (
179
+ timestamp,
180
+ session_id,
181
+ session_key,
182
+ tool_name,
183
+ success,
184
+ duration_ms
185
+ ) VALUES (
186
+ $timestamp,
187
+ $session_id,
188
+ $session_key,
189
+ $tool_name,
190
+ $success,
191
+ $duration_ms
192
+ )
193
+ `);
194
+ return database;
195
+ };
196
+
197
+ const writeUsageEvent = (event, ctx, logger) => {
198
+ const usage = event?.usage ?? {};
199
+ const timestamp = Date.now();
200
+ const date = new Date(timestamp).toISOString().slice(0, 10);
201
+ const inputTokens = coerceCount(usage.input);
202
+ const outputTokens = coerceCount(usage.output);
203
+ const cacheReadTokens = coerceCount(usage.cacheRead);
204
+ const cacheWriteTokens = coerceCount(usage.cacheWrite);
205
+ const fallbackTotal = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
206
+ const totalTokens = coerceCount(usage.total) || fallbackTotal;
207
+ if (totalTokens <= 0) return;
208
+ getDatabase();
209
+ insertUsageEventStmt.run({
210
+ $timestamp: timestamp,
211
+ $session_id: String(event?.sessionId || ctx?.sessionId || ""),
212
+ $session_key: String(ctx?.sessionKey || ""),
213
+ $run_id: String(event?.runId || ""),
214
+ $provider: String(event?.provider || "unknown"),
215
+ $model: String(event?.model || "unknown"),
216
+ $input_tokens: inputTokens,
217
+ $output_tokens: outputTokens,
218
+ $cache_read_tokens: cacheReadTokens,
219
+ $cache_write_tokens: cacheWriteTokens,
220
+ $total_tokens: totalTokens,
221
+ });
222
+ upsertUsageDailyStmt.run({
223
+ $date: date,
224
+ $model: String(event?.model || "unknown"),
225
+ $provider: String(event?.provider || "unknown"),
226
+ $input_tokens: inputTokens,
227
+ $output_tokens: outputTokens,
228
+ $cache_read_tokens: cacheReadTokens,
229
+ $cache_write_tokens: cacheWriteTokens,
230
+ $total_tokens: totalTokens,
231
+ });
232
+ if (logger?.debug) {
233
+ logger.debug(
234
+ `[${kPluginId}] usage event recorded model=${String(event?.model || "unknown")} total=${totalTokens}`,
235
+ );
236
+ }
237
+ };
238
+
239
+ const deriveToolSuccess = (event) => {
240
+ const message = event?.message;
241
+ if (!message || typeof message !== "object") {
242
+ return event?.error ? 0 : 1;
243
+ }
244
+ if (message?.isError === true) return 0;
245
+ if (message?.ok === false) return 0;
246
+ if (typeof message?.error === "string" && message.error.trim()) return 0;
247
+ return 1;
248
+ };
249
+
250
+ const writeToolEvent = (event, ctx) => {
251
+ const toolName = String(event?.toolName || "").trim();
252
+ if (!toolName) return;
253
+ const sessionKey = String(ctx?.sessionKey || "").trim();
254
+ const sessionId = String(ctx?.sessionId || "").trim();
255
+ if (!sessionKey && !sessionId) return;
256
+ getDatabase();
257
+ insertToolEventStmt.run({
258
+ $timestamp: Date.now(),
259
+ $session_id: sessionId,
260
+ $session_key: sessionKey,
261
+ $tool_name: toolName,
262
+ $success: deriveToolSuccess(event),
263
+ $duration_ms: coerceCount(event?.durationMs) || null,
264
+ });
265
+ };
266
+
267
+ return {
268
+ id: kPluginId,
269
+ name: "AlphaClaw Usage Tracker",
270
+ description: "Captures LLM and tool usage into SQLite for Usage UI",
271
+ register: (api) => {
272
+ const logger = api?.logger;
273
+ try {
274
+ getDatabase();
275
+ logger?.info?.(`[${kPluginId}] initialized db=${dbPath}`);
276
+ } catch (err) {
277
+ logger?.error?.(`[${kPluginId}] failed to initialize database: ${err?.message || err}`);
278
+ return;
279
+ }
280
+ api.on("llm_output", (event, ctx) => {
281
+ try {
282
+ writeUsageEvent(event, ctx, logger);
283
+ } catch (err) {
284
+ logger?.error?.(`[${kPluginId}] llm_output write error: ${err?.message || err}`);
285
+ }
286
+ });
287
+ api.on("tool_result_persist", (event, ctx) => {
288
+ try {
289
+ writeToolEvent(
290
+ {
291
+ ...event,
292
+ toolName: String(event?.toolName || ctx?.toolName || ""),
293
+ durationMs: event?.durationMs,
294
+ },
295
+ ctx,
296
+ );
297
+ } catch (err) {
298
+ logger?.error?.(`[${kPluginId}] tool_result_persist write error: ${err?.message || err}`);
299
+ }
300
+ return {};
301
+ });
302
+ },
303
+ };
304
+ };
305
+
306
+ const plugin = createPlugin();
307
+ module.exports = plugin;
308
+ module.exports.default = plugin;
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "usage-tracker",
3
+ "configSchema": {
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {}
7
+ }
8
+ }
@@ -102,7 +102,33 @@
102
102
  .file-tree-wrap {
103
103
  width: 100%;
104
104
  overflow-y: auto;
105
- padding: 8px 0;
105
+ padding: 6px 0 8px;
106
+ }
107
+
108
+ .file-tree-search {
109
+ padding: 0 8px 6px;
110
+ }
111
+
112
+ .file-tree-search-input {
113
+ width: 100%;
114
+ height: 28px;
115
+ border-radius: 7px;
116
+ border: 1px solid var(--border);
117
+ background: rgba(255, 255, 255, 0.02);
118
+ color: var(--text);
119
+ font-size: 12px;
120
+ padding: 0 9px;
121
+ outline: none;
122
+ font-family: inherit;
123
+ }
124
+
125
+ .file-tree-search-input::placeholder {
126
+ color: var(--text-dim);
127
+ }
128
+
129
+ .file-tree-search-input:focus {
130
+ border-color: rgba(99, 235, 255, 0.45);
131
+ box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18);
106
132
  }
107
133
 
108
134
  .file-tree-wrap::-webkit-scrollbar {
@@ -153,6 +179,11 @@
153
179
  color: var(--accent);
154
180
  }
155
181
 
182
+ .tree-item > a.soft-active:not(.active) {
183
+ background: rgba(99, 235, 255, 0.06);
184
+ color: var(--text);
185
+ }
186
+
156
187
  .tree-item > a.active::before {
157
188
  content: '';
158
189
  position: absolute;
@@ -317,6 +348,16 @@
317
348
  flex: 1;
318
349
  }
319
350
 
351
+ .file-viewer-preview-pill {
352
+ margin-right: 8px;
353
+ font-size: 11px;
354
+ color: var(--text-muted);
355
+ border: 1px solid var(--border);
356
+ border-radius: 999px;
357
+ padding: 3px 8px;
358
+ line-height: 1;
359
+ }
360
+
320
361
  .file-viewer-tab {
321
362
  display: flex;
322
363
  align-items: center;
@@ -791,6 +832,15 @@
791
832
  font-size: 12px;
792
833
  }
793
834
 
835
+ .file-viewer-loading-shell {
836
+ flex: 1 1 auto;
837
+ min-height: 140px;
838
+ display: flex;
839
+ align-items: center;
840
+ justify-content: center;
841
+ color: var(--text-muted);
842
+ }
843
+
794
844
  .file-viewer-state-error {
795
845
  color: #f87171;
796
846
  }
@@ -1,8 +1,9 @@
1
1
  /* ── App shell grid ─────────────────────────────── */
2
2
 
3
3
  .app-shell {
4
+ --sidebar-width: 220px;
4
5
  display: grid;
5
- grid-template-columns: 220px 0 minmax(0, 1fr);
6
+ grid-template-columns: var(--sidebar-width) 0px minmax(0, 1fr);
6
7
  grid-template-rows: auto 1fr 24px;
7
8
  height: 100vh;
8
9
  position: relative;
@@ -286,6 +287,7 @@
286
287
 
287
288
  @media (max-width: 768px) {
288
289
  .app-shell {
290
+ --sidebar-width: 0px !important;
289
291
  grid-template-columns: 1fr;
290
292
  grid-template-rows: auto 1fr 24px;
291
293
  }
@@ -451,3 +451,38 @@ textarea:focus {
451
451
  animation-timing-function: linear;
452
452
  }
453
453
  }
454
+
455
+ /* Reusable segmented control (pill toggle). */
456
+ .ac-segmented-control {
457
+ display: inline-flex;
458
+ align-items: center;
459
+ border: 1px solid var(--panel-border-contrast);
460
+ border-radius: 8px;
461
+ overflow: hidden;
462
+ background: rgba(255, 255, 255, 0.02);
463
+ height: 28px;
464
+ }
465
+
466
+ .ac-segmented-control-button {
467
+ border: 0;
468
+ background: transparent;
469
+ color: var(--text-muted);
470
+ font-family: inherit;
471
+ font-size: 12px;
472
+ letter-spacing: 0.03em;
473
+ height: 100%;
474
+ line-height: 1;
475
+ padding: 0 10px;
476
+ cursor: pointer;
477
+ transition: color 0.12s, background 0.12s;
478
+ }
479
+
480
+ .ac-segmented-control-button:hover {
481
+ color: var(--text);
482
+ background: rgba(255, 255, 255, 0.03);
483
+ }
484
+
485
+ .ac-segmented-control-button.active {
486
+ color: var(--accent);
487
+ background: var(--bg-active);
488
+ }
@@ -34,7 +34,7 @@ import { Welcome } from "./components/welcome.js";
34
34
  import { Envars } from "./components/envars.js";
35
35
  import { Webhooks } from "./components/webhooks.js";
36
36
  import { ToastContainer, showToast } from "./components/toast.js";
37
- import { TelegramWorkspace } from "./components/telegram-workspace.js";
37
+ import { TelegramWorkspace } from "./components/telegram-workspace/index.js";
38
38
  import { ChevronDownIcon } from "./components/icons.js";
39
39
  import { UpdateActionButton } from "./components/update-action-button.js";
40
40
  import { GlobalRestartBanner } from "./components/global-restart-banner.js";
@@ -42,31 +42,19 @@ import { LoadingSpinner } from "./components/loading-spinner.js";
42
42
  import { WatchdogTab } from "./components/watchdog-tab.js";
43
43
  import { FileViewer } from "./components/file-viewer.js";
44
44
  import { AppSidebar } from "./components/sidebar.js";
45
+ import { UsageTab } from "./components/usage-tab.js";
46
+ import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js";
45
47
  const html = htm.bind(h);
46
48
  const kDefaultUiTab = "general";
47
- const kUiSettingsStorageKey = "alphaclaw.uiSettings";
48
- const kLegacyUiSettingsStorageKey = "alphaclawUiSettings";
49
49
  const kDefaultSidebarWidthPx = 220;
50
50
  const kSidebarMinWidthPx = 180;
51
51
  const kSidebarMaxWidthPx = 460;
52
52
  const kBrowseLastPathUiSettingKey = "browseLastPath";
53
+ const kLastMenuRouteUiSettingKey = "lastMenuRoute";
53
54
 
54
55
  const clampSidebarWidth = (value) =>
55
56
  Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value));
56
57
 
57
- const readUiSettings = () => {
58
- try {
59
- const raw =
60
- window.localStorage.getItem(kUiSettingsStorageKey) ||
61
- window.localStorage.getItem(kLegacyUiSettingsStorageKey);
62
- if (!raw) return {};
63
- const parsed = JSON.parse(raw);
64
- return parsed && typeof parsed === "object" ? parsed : {};
65
- } catch {
66
- return {};
67
- }
68
- };
69
-
70
58
  const getHashPath = () => {
71
59
  const hash = window.location.hash.replace(/^#/, "");
72
60
  if (!hash) return `/${kDefaultUiTab}`;
@@ -407,7 +395,20 @@ const App = () => {
407
395
  ? settings[kBrowseLastPathUiSettingKey]
408
396
  : "";
409
397
  });
398
+ const [lastMenuRoute, setLastMenuRoute] = useState(() => {
399
+ const settings = readUiSettings();
400
+ const storedRoute = settings[kLastMenuRouteUiSettingKey];
401
+ if (
402
+ typeof storedRoute === "string" &&
403
+ storedRoute.startsWith("/") &&
404
+ !storedRoute.startsWith("/browse")
405
+ ) {
406
+ return storedRoute;
407
+ }
408
+ return `/${kDefaultUiTab}`;
409
+ });
410
410
  const [isResizingSidebar, setIsResizingSidebar] = useState(false);
411
+ const [browsePreviewPath, setBrowsePreviewPath] = useState("");
411
412
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
412
413
  const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false);
413
414
  const [restartRequired, setRestartRequired] = useState(false);
@@ -603,6 +604,7 @@ const App = () => {
603
604
  setMobileSidebarOpen(false);
604
605
  };
605
606
  const navigateToBrowseFile = (relativePath) => {
607
+ setBrowsePreviewPath("");
606
608
  setLocation(buildBrowseRoute(relativePath));
607
609
  setMobileSidebarOpen(false);
608
610
  };
@@ -618,7 +620,8 @@ const App = () => {
618
620
  const handleSelectSidebarTab = (nextTab) => {
619
621
  setSidebarTab(nextTab);
620
622
  if (nextTab === "menu" && location.startsWith("/browse")) {
621
- setLocation("/general");
623
+ setBrowsePreviewPath("");
624
+ setLocation(lastMenuRoute || `/${kDefaultUiTab}`);
622
625
  return;
623
626
  }
624
627
  if (nextTab === "browse" && !location.startsWith("/browse")) {
@@ -642,10 +645,16 @@ const App = () => {
642
645
 
643
646
  const kNavSections = [
644
647
  {
645
- label: "Status",
648
+ label: "Setup",
646
649
  items: [
647
650
  { id: "general", label: "General" },
651
+ ],
652
+ },
653
+ {
654
+ label: "Monitoring",
655
+ items: [
648
656
  { id: "watchdog", label: "Watchdog" },
657
+ { id: "usage", label: "Usage" },
649
658
  ],
650
659
  },
651
660
  {
@@ -681,6 +690,8 @@ const App = () => {
681
690
  ? "providers"
682
691
  : location.startsWith("/watchdog")
683
692
  ? "watchdog"
693
+ : location.startsWith("/usage")
694
+ ? "usage"
684
695
  : location.startsWith("/envars")
685
696
  ? "envars"
686
697
  : location.startsWith("/webhooks")
@@ -695,6 +706,19 @@ const App = () => {
695
706
  });
696
707
  }, [location]);
697
708
 
709
+ useEffect(() => {
710
+ if (location.startsWith("/browse")) return;
711
+ setBrowsePreviewPath("");
712
+ }, [location]);
713
+
714
+ useEffect(() => {
715
+ if (location.startsWith("/browse")) return;
716
+ if (location === "/telegram") return;
717
+ setLastMenuRoute((currentRoute) =>
718
+ currentRoute === location ? currentRoute : location,
719
+ );
720
+ }, [location]);
721
+
698
722
  useEffect(() => {
699
723
  if (!isBrowseRoute) return;
700
724
  if (!selectedBrowsePath) return;
@@ -707,10 +731,9 @@ const App = () => {
707
731
  const settings = readUiSettings();
708
732
  settings.sidebarWidthPx = sidebarWidthPx;
709
733
  settings[kBrowseLastPathUiSettingKey] = lastBrowsePath;
710
- try {
711
- window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(settings));
712
- } catch {}
713
- }, [sidebarWidthPx, lastBrowsePath]);
734
+ settings[kLastMenuRouteUiSettingKey] = lastMenuRoute;
735
+ writeUiSettings(settings);
736
+ }, [sidebarWidthPx, lastBrowsePath, lastMenuRoute]);
714
737
 
715
738
  const resizeSidebarWithClientX = useCallback((clientX) => {
716
739
  const shellElement = appShellRef.current;
@@ -759,7 +782,7 @@ const App = () => {
759
782
  <div
760
783
  class="app-shell"
761
784
  ref=${appShellRef}
762
- style=${{ gridTemplateColumns: `${sidebarWidthPx}px 0px minmax(0, 1fr)` }}
785
+ style=${{ "--sidebar-width": `${sidebarWidthPx}px` }}
763
786
  >
764
787
  <${GlobalRestartBanner}
765
788
  visible=${restartRequired}
@@ -780,6 +803,7 @@ const App = () => {
780
803
  onSelectNavItem=${handleSelectNavItem}
781
804
  selectedBrowsePath=${selectedBrowsePath}
782
805
  onSelectBrowseFile=${navigateToBrowseFile}
806
+ onPreviewBrowseFile=${setBrowsePreviewPath}
783
807
  acHasUpdate=${acHasUpdate}
784
808
  acLatest=${acLatest}
785
809
  acDismissed=${acDismissed}
@@ -824,7 +848,11 @@ const App = () => {
824
848
  ${isBrowseRoute
825
849
  ? html`
826
850
  <${FileViewer}
827
- filePath=${selectedBrowsePath}
851
+ filePath=${browsePreviewPath || selectedBrowsePath}
852
+ isPreviewOnly=${Boolean(
853
+ browsePreviewPath &&
854
+ browsePreviewPath !== selectedBrowsePath,
855
+ )}
828
856
  />
829
857
  `
830
858
  : html`
@@ -867,6 +895,27 @@ const App = () => {
867
895
  />
868
896
  </div>
869
897
  </${Route}>
898
+ <${Route} path="/usage/:sessionId">
899
+ ${(params) => html`
900
+ <div class="pt-4">
901
+ <${UsageTab}
902
+ sessionId=${decodeURIComponent(params.sessionId || "")}
903
+ onSelectSession=${(id) =>
904
+ setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)}
905
+ onBackToSessions=${() => setLocation("/usage")}
906
+ />
907
+ </div>
908
+ `}
909
+ </${Route}>
910
+ <${Route} path="/usage">
911
+ <div class="pt-4">
912
+ <${UsageTab}
913
+ onSelectSession=${(id) =>
914
+ setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)}
915
+ onBackToSessions=${() => setLocation("/usage")}
916
+ />
917
+ </div>
918
+ </${Route}>
870
919
  <${Route} path="/envars">
871
920
  <div class="pt-4">
872
921
  <${Envars} onRestartRequired=${setRestartRequired} />