@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-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 (68) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. package/lib/public/js/components/usage-tab.js +0 -531
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchGoogleAccounts } from "../../lib/api.js";
3
+
4
+ export const useGoogleAccounts = ({ gatewayStatus }) => {
5
+ const [accounts, setAccounts] = useState([]);
6
+ const [loading, setLoading] = useState(true);
7
+ const [hasCompanyCredentials, setHasCompanyCredentials] = useState(false);
8
+ const [hasPersonalCredentials, setHasPersonalCredentials] = useState(false);
9
+
10
+ const refreshAccounts = useCallback(async () => {
11
+ setLoading(true);
12
+ try {
13
+ const data = await fetchGoogleAccounts();
14
+ if (data.ok) {
15
+ setAccounts(Array.isArray(data.accounts) ? data.accounts : []);
16
+ setHasCompanyCredentials(Boolean(data.hasCompanyCredentials));
17
+ setHasPersonalCredentials(Boolean(data.hasPersonalCredentials));
18
+ }
19
+ } finally {
20
+ setLoading(false);
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ refreshAccounts();
26
+ }, [refreshAccounts]);
27
+
28
+ useEffect(() => {
29
+ if (gatewayStatus === "running") {
30
+ refreshAccounts();
31
+ }
32
+ }, [gatewayStatus, refreshAccounts]);
33
+
34
+ return {
35
+ accounts,
36
+ loading,
37
+ hasCompanyCredentials,
38
+ hasPersonalCredentials,
39
+ refreshAccounts,
40
+ };
41
+ };
@@ -248,3 +248,29 @@ export const LockLineIcon = ({ className = "" }) => html`
248
248
  />
249
249
  </svg>
250
250
  `;
251
+
252
+ export const DeleteBinLineIcon = ({ className = "" }) => html`
253
+ <svg
254
+ class=${className}
255
+ viewBox="0 0 24 24"
256
+ fill="currentColor"
257
+ aria-hidden="true"
258
+ >
259
+ <path
260
+ d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"
261
+ />
262
+ </svg>
263
+ `;
264
+
265
+ export const RestartLineIcon = ({ className = "" }) => html`
266
+ <svg
267
+ class=${className}
268
+ viewBox="0 0 24 24"
269
+ fill="currentColor"
270
+ aria-hidden="true"
271
+ >
272
+ <path
273
+ d="M18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"
274
+ />
275
+ </svg>
276
+ `;
@@ -68,7 +68,7 @@ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) {
68
68
  <button
69
69
  type="button"
70
70
  onclick=${() => setShowAll((prev) => !prev)}
71
- class="text-xs text-gray-500 hover:text-gray-300"
71
+ class="ml-3 text-xs text-gray-500 hover:text-gray-300"
72
72
  >
73
73
  ${showAll ? 'Show fewer services' : `Show more services (${SERVICES.length - kVisibleCount})`}
74
74
  </button>
@@ -2,6 +2,7 @@ import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { fetchBrowseGitSummary, syncBrowseChanges } from "../lib/api.js";
5
+ import { formatLocaleDateTime } from "../lib/format.js";
5
6
  import { ActionButton } from "./action-button.js";
6
7
  import { GitBranchLineIcon, GithubFillIcon } from "./icons.js";
7
8
  import { LoadingSpinner } from "./loading-spinner.js";
@@ -13,12 +14,10 @@ const kSyncCommitFileNameLimit = 4;
13
14
  const kCommitHistoryLimit = 12;
14
15
 
15
16
  const formatCommitTime = (unixSeconds) => {
16
- if (!unixSeconds) return "";
17
- try {
18
- return new Date(unixSeconds * 1000).toLocaleString();
19
- } catch {
20
- return "";
21
- }
17
+ return formatLocaleDateTime(unixSeconds, {
18
+ fallback: "",
19
+ valueIsUnixSeconds: true,
20
+ });
22
21
  };
23
22
 
24
23
  const getRepoName = (summary) => {
@@ -43,8 +42,8 @@ const getChangedFilePresentation = (changedFile) => {
43
42
  return {
44
43
  statusLabel: "D",
45
44
  statusClass: "is-deleted",
46
- rowClass: "",
47
- canOpen: false,
45
+ rowClass: "is-clickable",
46
+ canOpen: true,
48
47
  };
49
48
  }
50
49
  return {
@@ -62,6 +61,14 @@ const formatDelta = (value, prefix) => {
62
61
  return `${prefix}${numericValue}`;
63
62
  };
64
63
 
64
+ const isDirectoryChangePath = (changedPath, statusKind) => {
65
+ const safePath = String(changedPath || "").trim();
66
+ const safeStatusKind = String(statusKind || "").toUpperCase();
67
+ if (!safePath) return false;
68
+ if (safePath.endsWith("/")) return true;
69
+ return safeStatusKind === "U" && safePath.endsWith("\\");
70
+ };
71
+
65
72
  const getRemoteSyncPresentation = (summary) => {
66
73
  const safeState = String(summary?.syncState || "").trim();
67
74
  const aheadCount = Number(summary?.aheadCount) || 0;
@@ -108,31 +115,41 @@ const getRemoteSyncPresentation = (summary) => {
108
115
  };
109
116
  };
110
117
 
111
- const buildSyncCommitMessage = (changedFiles) => {
112
- const filePaths = Array.isArray(changedFiles)
113
- ? changedFiles
114
- .map((file) => String(file?.path || "").trim())
115
- .filter(Boolean)
116
- : [];
117
- const totalCount = filePaths.length;
118
+ const buildSyncCommitMessage = (summary) => {
119
+ const changedFiles = Array.isArray(summary?.changedFiles) ? summary.changedFiles : [];
120
+ const changedFilesCount = Number(summary?.changedFilesCount) || 0;
121
+ const filePaths = changedFiles
122
+ .map((file) => String(file?.path || "").trim())
123
+ .filter(Boolean);
124
+ const totalCount = changedFilesCount || filePaths.length;
118
125
  if (totalCount <= 0) return "sync changes";
119
126
 
120
- const fileNames = filePaths.map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath);
127
+ const fileNames = filePaths
128
+ .map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath);
121
129
  const uniqueFileNames = Array.from(new Set(fileNames));
130
+ if (uniqueFileNames.length <= 0) {
131
+ const noun = totalCount === 1 ? "file" : "files";
132
+ return `Edited ${totalCount} ${noun}`;
133
+ }
134
+
122
135
  const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit);
123
- const remainingCount = Math.max(0, uniqueFileNames.length - shownFileNames.length);
136
+ const remainingCount = Math.max(0, totalCount - shownFileNames.length);
124
137
  const noun = totalCount === 1 ? "file" : "files";
125
138
  const suffix = remainingCount > 0 ? ` +${remainingCount} more` : "";
126
139
  return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`;
127
140
  };
128
141
 
129
- export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
142
+ export const SidebarGitPanel = ({
143
+ onSelectFile = () => {},
144
+ isActive = true,
145
+ }) => {
130
146
  const [loading, setLoading] = useState(true);
131
147
  const [syncing, setSyncing] = useState(false);
132
148
  const [error, setError] = useState("");
133
149
  const [summary, setSummary] = useState(null);
134
150
 
135
151
  useEffect(() => {
152
+ if (!isActive) return () => {};
136
153
  let active = true;
137
154
  let intervalId = null;
138
155
 
@@ -164,7 +181,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
164
181
  if (intervalId) window.clearInterval(intervalId);
165
182
  window.removeEventListener("alphaclaw:browse-file-saved", handleFileSaved);
166
183
  };
167
- }, []);
184
+ }, [isActive]);
168
185
 
169
186
  if (loading) {
170
187
  return html`
@@ -194,7 +211,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
194
211
  if (!canSyncChanges || syncing) return;
195
212
  try {
196
213
  setSyncing(true);
197
- const commitMessage = buildSyncCommitMessage(summary?.changedFiles || []);
214
+ const commitMessage = buildSyncCommitMessage(summary);
198
215
  const syncResult = await syncBrowseChanges(commitMessage);
199
216
  if (syncResult?.committed || syncResult?.pushed) {
200
217
  window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced"));
@@ -270,6 +287,17 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
270
287
  title=${changedPath}
271
288
  onclick=${() => {
272
289
  if (!presentation.canOpen || !changedPath) return;
290
+ const directorySelection = isDirectoryChangePath(
291
+ changedPath,
292
+ changedFile?.statusKind,
293
+ );
294
+ if (directorySelection) {
295
+ onSelectFile(changedPath, {
296
+ directory: true,
297
+ preservePreview: true,
298
+ });
299
+ return;
300
+ }
273
301
  onSelectFile(changedPath, { view: "diff" });
274
302
  }}
275
303
  >
@@ -89,6 +89,8 @@ export const AppSidebar = ({
89
89
  const layoutElement = browseLayoutRef.current;
90
90
  if (!layoutElement || typeof ResizeObserver === "undefined") return () => {};
91
91
  const observer = new ResizeObserver(() => {
92
+ const layoutRect = layoutElement.getBoundingClientRect();
93
+ if (layoutRect.height <= 0) return;
92
94
  setBrowseBottomPanelHeightPx((currentHeight) =>
93
95
  getClampedBrowseBottomPanelHeight(currentHeight),
94
96
  );
@@ -172,83 +174,99 @@ export const AppSidebar = ({
172
174
  <${FolderLineIcon} className="sidebar-tab-icon" />
173
175
  </button>
174
176
  </div>
175
- ${sidebarTab === "menu"
176
- ? navSections.map(
177
- (section) => html`
178
- <div class="sidebar-label">${section.label}</div>
179
- <nav class="sidebar-nav">
180
- ${section.items.map(
181
- (item) => html`
182
- <a
183
- class=${selectedNavId === item.id ? "active" : ""}
184
- onclick=${() => onSelectNavItem(item.id)}
185
- >
186
- ${item.label}
187
- </a>
188
- `,
189
- )}
190
- </nav>
191
- `,
192
- )
193
- : html`
194
- <div class="sidebar-browse-layout" ref=${browseLayoutRef}>
195
- <div
196
- class="sidebar-browse-panel"
197
- >
198
- <${FileTree}
199
- onSelectFile=${onSelectBrowseFile}
200
- selectedPath=${selectedBrowsePath}
201
- onPreviewFile=${onPreviewBrowseFile}
177
+ <div
178
+ style=${{
179
+ display: sidebarTab === "menu" ? "flex" : "none",
180
+ flexDirection: "column",
181
+ flex: "1 1 auto",
182
+ minHeight: 0,
183
+ }}
184
+ >
185
+ ${navSections.map(
186
+ (section) => html`
187
+ <div class="sidebar-label">${section.label}</div>
188
+ <nav class="sidebar-nav">
189
+ ${section.items.map(
190
+ (item) => html`
191
+ <a
192
+ class=${selectedNavId === item.id ? "active" : ""}
193
+ onclick=${() => onSelectNavItem(item.id)}
194
+ >
195
+ ${item.label}
196
+ </a>
197
+ `,
198
+ )}
199
+ </nav>
200
+ `,
201
+ )}
202
+ <div class="sidebar-footer">
203
+ ${acHasUpdate && acLatest && !acDismissed
204
+ ? html`
205
+ <${UpdateActionButton}
206
+ onClick=${onAcUpdate}
207
+ loading=${acUpdating}
208
+ warning=${true}
209
+ idleLabel=${`Update to v${acLatest}`}
210
+ loadingLabel="Updating..."
211
+ className="w-full justify-center"
202
212
  />
203
- </div>
204
- <div
205
- class=${`sidebar-browse-resizer ${isResizingBrowsePanels ? "is-resizing" : ""}`}
206
- onpointerdown=${onBrowsePanelResizerPointerDown}
207
- role="separator"
208
- aria-orientation="horizontal"
209
- aria-label="Resize browse and git panels"
210
- ></div>
211
- <div class="sidebar-browse-bottom">
212
- <div
213
- class="sidebar-browse-bottom-inner"
214
- ref=${browseBottomPanelRef}
215
- style=${{ height: `${browseBottomPanelHeightPx}px` }}
216
- >
217
- <${SidebarGitPanel} onSelectFile=${onSelectBrowseFile} />
218
- ${acHasUpdate && acLatest && !acDismissed
219
- ? html`
220
- <${UpdateActionButton}
221
- onClick=${onAcUpdate}
222
- loading=${acUpdating}
223
- warning=${true}
224
- idleLabel=${`Update to v${acLatest}`}
225
- loadingLabel="Updating..."
226
- className="w-full justify-center"
227
- />
228
- `
229
- : null}
230
- </div>
231
- </div>
232
- </div>
233
- `}
234
- ${sidebarTab === "menu"
235
- ? html`
236
- <div class="sidebar-footer">
237
- ${acHasUpdate && acLatest && !acDismissed
238
- ? html`
239
- <${UpdateActionButton}
240
- onClick=${onAcUpdate}
241
- loading=${acUpdating}
242
- warning=${true}
243
- idleLabel=${`Update to v${acLatest}`}
244
- loadingLabel="Updating..."
245
- className="w-full justify-center"
246
- />
247
- `
248
- : null}
213
+ `
214
+ : null}
215
+ </div>
216
+ </div>
217
+ <div
218
+ style=${{
219
+ display: sidebarTab === "browse" ? "flex" : "none",
220
+ flexDirection: "column",
221
+ flex: "1 1 auto",
222
+ minHeight: 0,
223
+ overflow: "hidden",
224
+ }}
225
+ >
226
+ <div class="sidebar-browse-layout" ref=${browseLayoutRef}>
227
+ <div
228
+ class="sidebar-browse-panel"
229
+ >
230
+ <${FileTree}
231
+ onSelectFile=${onSelectBrowseFile}
232
+ selectedPath=${selectedBrowsePath}
233
+ onPreviewFile=${onPreviewBrowseFile}
234
+ isActive=${sidebarTab === "browse"}
235
+ />
236
+ </div>
237
+ <div
238
+ class=${`sidebar-browse-resizer ${isResizingBrowsePanels ? "is-resizing" : ""}`}
239
+ onpointerdown=${onBrowsePanelResizerPointerDown}
240
+ role="separator"
241
+ aria-orientation="horizontal"
242
+ aria-label="Resize browse and git panels"
243
+ ></div>
244
+ <div class="sidebar-browse-bottom">
245
+ <div
246
+ class="sidebar-browse-bottom-inner"
247
+ ref=${browseBottomPanelRef}
248
+ style=${{ height: `${browseBottomPanelHeightPx}px` }}
249
+ >
250
+ <${SidebarGitPanel}
251
+ onSelectFile=${onSelectBrowseFile}
252
+ isActive=${sidebarTab === "browse"}
253
+ />
254
+ ${acHasUpdate && acLatest && !acDismissed
255
+ ? html`
256
+ <${UpdateActionButton}
257
+ onClick=${onAcUpdate}
258
+ loading=${acUpdating}
259
+ warning=${true}
260
+ idleLabel=${`Update to v${acLatest}`}
261
+ loadingLabel="Updating..."
262
+ className="w-full justify-center"
263
+ />
264
+ `
265
+ : null}
249
266
  </div>
250
- `
251
- : null}
267
+ </div>
268
+ </div>
269
+ </div>
252
270
  </div>
253
271
  `;
254
272
  };
@@ -1,5 +1,6 @@
1
1
  import { h } from 'https://esm.sh/preact';
2
2
  import { useState, useEffect } from 'https://esm.sh/preact/hooks';
3
+ import { createPortal } from 'https://esm.sh/preact/compat';
3
4
  import htm from 'https://esm.sh/htm';
4
5
  const html = htm.bind(h);
5
6
 
@@ -50,11 +51,14 @@ export function ToastContainer({
50
51
 
51
52
  if (toasts.length === 0) return null;
52
53
 
53
- return html`<div class=${className}>
54
- ${toasts.map(t => html`
55
- <div key=${t.id} class="${kToastClassByType[normalizeToastType(t.type)]} px-4 py-2 rounded-lg text-sm">
56
- ${t.text}
57
- </div>
58
- `)}
59
- </div>`;
54
+ return createPortal(
55
+ html`<div class=${className} style=${{ zIndex: 70 }}>
56
+ ${toasts.map(t => html`
57
+ <div key=${t.id} class="${kToastClassByType[normalizeToastType(t.type)]} px-4 py-2 rounded-lg text-sm">
58
+ ${t.text}
59
+ </div>
60
+ `)}
61
+ </div>`,
62
+ document.body,
63
+ );
60
64
  }
@@ -0,0 +1,31 @@
1
+ export const kColorPalette = [
2
+ "#7dd3fc",
3
+ "#22d3ee",
4
+ "#34d399",
5
+ "#fbbf24",
6
+ "#fb7185",
7
+ "#a78bfa",
8
+ "#f472b6",
9
+ "#60a5fa",
10
+ "#4ade80",
11
+ "#f97316",
12
+ ];
13
+
14
+ export const kBadgeToneClass = {
15
+ cyan: "border-cyan-400/30 text-cyan-300 bg-cyan-400/10",
16
+ blue: "border-blue-400/30 text-blue-300 bg-blue-400/10",
17
+ purple: "border-purple-400/30 text-purple-300 bg-purple-400/10",
18
+ gray: "border-gray-400/30 text-gray-400 bg-gray-400/10",
19
+ };
20
+
21
+ export const kRangeOptions = [
22
+ { label: "7d", value: 7 },
23
+ { label: "30d", value: 30 },
24
+ { label: "90d", value: 90 },
25
+ ];
26
+
27
+ export const kDefaultUsageDays = 30;
28
+ export const kDefaultUsageMetric = "tokens";
29
+ export const kUsageDaysUiSettingKey = "usageDays";
30
+ export const kUsageMetricUiSettingKey = "usageMetric";
31
+ export const kUsageSourceOrder = ["chat", "hooks", "cron"];
@@ -0,0 +1,24 @@
1
+ import { kColorPalette } from "./constants.js";
2
+
3
+ export const toLocalDayKey = (value) => {
4
+ const d = value instanceof Date ? value : new Date(value ?? Date.now());
5
+ const year = d.getFullYear();
6
+ const month = String(d.getMonth() + 1).padStart(2, "0");
7
+ const day = String(d.getDate()).padStart(2, "0");
8
+ return `${year}-${month}-${day}`;
9
+ };
10
+
11
+ export const toChartColor = (key) => {
12
+ const raw = String(key || "");
13
+ let hash = 0;
14
+ for (let index = 0; index < raw.length; index += 1) {
15
+ hash = ((hash << 5) - hash + raw.charCodeAt(index)) | 0;
16
+ }
17
+ return kColorPalette[Math.abs(hash) % kColorPalette.length];
18
+ };
19
+
20
+ export const renderSourceLabel = (source) => {
21
+ if (source === "hooks") return "Hooks";
22
+ if (source === "cron") return "Cron";
23
+ return "Chat";
24
+ };
@@ -0,0 +1,72 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../action-button.js";
4
+ import { PageHeader } from "../page-header.js";
5
+ import { OverviewSection } from "./overview-section.js";
6
+ import { SessionsSection } from "./sessions-section.js";
7
+ import { useUsageTab } from "./use-usage-tab.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ export const UsageTab = ({ sessionId = "" }) => {
12
+ const { state, actions } = useUsageTab({ sessionId });
13
+
14
+ const handleToggleSession = (itemSessionId, isOpen) => {
15
+ if (isOpen) {
16
+ actions.setExpandedSessionIds((currentValue) =>
17
+ currentValue.includes(itemSessionId) ? currentValue : [...currentValue, itemSessionId],
18
+ );
19
+ if (!state.sessionDetailById[itemSessionId] && !state.loadingDetailById[itemSessionId]) {
20
+ actions.loadSessionDetail(itemSessionId);
21
+ }
22
+ return;
23
+ }
24
+ actions.setExpandedSessionIds((currentValue) =>
25
+ currentValue.filter((value) => value !== itemSessionId),
26
+ );
27
+ };
28
+
29
+ return html`
30
+ <div class="space-y-4">
31
+ <${PageHeader}
32
+ title="Usage"
33
+ actions=${html`
34
+ <${ActionButton}
35
+ onClick=${actions.loadSummary}
36
+ loading=${state.loadingSummary}
37
+ tone="secondary"
38
+ size="sm"
39
+ idleLabel="Refresh"
40
+ loadingMode="inline"
41
+ />
42
+ `}
43
+ />
44
+ ${state.error
45
+ ? html`<div class="text-xs text-red-300 bg-red-950/30 border border-red-900 rounded px-3 py-2">
46
+ ${state.error}
47
+ </div>`
48
+ : null}
49
+ ${state.loadingSummary && !state.summary
50
+ ? html`<div class="text-sm text-[var(--text-muted)]">Loading usage summary...</div>`
51
+ : html`
52
+ <${OverviewSection}
53
+ summary=${state.summary}
54
+ periodSummary=${state.periodSummary}
55
+ metric=${state.metric}
56
+ days=${state.days}
57
+ overviewCanvasRef=${state.overviewCanvasRef}
58
+ onDaysChange=${actions.setDays}
59
+ onMetricChange=${actions.setMetric}
60
+ />
61
+ `}
62
+ <${SessionsSection}
63
+ sessions=${state.sessions}
64
+ loadingSessions=${state.loadingSessions}
65
+ expandedSessionIds=${state.expandedSessionIds}
66
+ loadingDetailById=${state.loadingDetailById}
67
+ sessionDetailById=${state.sessionDetailById}
68
+ onToggleSession=${handleToggleSession}
69
+ />
70
+ </div>
71
+ `;
72
+ };