@chrysb/alphaclaw 0.4.0 → 0.4.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/lib/public/css/shell.css +21 -19
  2. package/lib/public/css/theme.css +17 -0
  3. package/lib/public/js/app.js +80 -5
  4. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  5. package/lib/public/js/components/file-viewer/index.js +3 -0
  6. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  7. package/lib/public/js/components/file-viewer/toolbar.js +13 -0
  8. package/lib/public/js/components/file-viewer/use-file-viewer.js +48 -13
  9. package/lib/public/js/components/google/account-row.js +34 -1
  10. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  11. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  12. package/lib/public/js/components/google/index.js +193 -44
  13. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  14. package/lib/public/js/components/scope-picker.js +1 -1
  15. package/lib/public/js/components/sidebar-git-panel.js +5 -6
  16. package/lib/public/js/components/sidebar.js +3 -1
  17. package/lib/public/js/components/telegram-workspace/onboarding.js +1 -1
  18. package/lib/public/js/components/toast.js +11 -7
  19. package/lib/public/js/components/usage-tab/constants.js +31 -0
  20. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  21. package/lib/public/js/components/usage-tab/index.js +72 -0
  22. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  23. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  24. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  25. package/lib/public/js/components/webhooks.js +180 -127
  26. package/lib/public/js/lib/api.js +106 -1
  27. package/lib/public/js/lib/format.js +71 -0
  28. package/lib/server/constants.js +27 -0
  29. package/lib/server/gmail-push.js +109 -0
  30. package/lib/server/gmail-serve.js +254 -0
  31. package/lib/server/gmail-watch.js +705 -0
  32. package/lib/server/google-state.js +130 -0
  33. package/lib/server/helpers.js +5 -7
  34. package/lib/server/internal-files-migration.js +31 -3
  35. package/lib/server/onboarding/openclaw.js +9 -1
  36. package/lib/server/routes/gmail.js +128 -0
  37. package/lib/server/routes/google.js +19 -0
  38. package/lib/server/routes/system.js +107 -0
  39. package/lib/server/routes/usage.js +29 -2
  40. package/lib/server/routes/webhooks.js +47 -14
  41. package/lib/server/usage-db.js +283 -15
  42. package/lib/server/watchdog.js +66 -0
  43. package/lib/server/webhook-middleware.js +99 -1
  44. package/lib/server/webhooks.js +213 -64
  45. package/lib/server.js +27 -0
  46. package/lib/setup/gitignore +3 -0
  47. package/lib/setup/hourly-git-sync.sh +1 -1
  48. package/package.json +1 -1
  49. package/lib/public/js/components/usage-tab.js +0 -531
@@ -1,9 +1,15 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
3
8
  import htm from "https://esm.sh/htm";
4
9
  import {
5
10
  checkGoogleApis,
6
11
  disconnectGoogle,
12
+ fetchGoogleCredentials,
7
13
  saveGoogleAccount,
8
14
  } from "../../lib/api.js";
9
15
  import { getDefaultScopes, toggleScopeLogic } from "../scope-picker.js";
@@ -15,6 +21,8 @@ import { ActionButton } from "../action-button.js";
15
21
  import { GoogleAccountRow } from "./account-row.js";
16
22
  import { AddGoogleAccountModal } from "./add-account-modal.js";
17
23
  import { useGoogleAccounts } from "./use-google-accounts.js";
24
+ import { useGmailWatch } from "./use-gmail-watch.js";
25
+ import { GmailSetupWizard } from "./gmail-setup-wizard.js";
18
26
 
19
27
  const html = htm.bind(h);
20
28
 
@@ -26,13 +34,13 @@ const isPersonalAccount = (account = {}) => Boolean(account.personal);
26
34
 
27
35
  const kGoogleIconPath = "/assets/icons/google_icon.svg";
28
36
 
29
- export const Google = ({ gatewayStatus }) => {
30
- const {
31
- accounts,
32
- loading,
33
- hasCompanyCredentials,
34
- refreshAccounts,
35
- } = useGoogleAccounts({ gatewayStatus });
37
+ export const Google = ({
38
+ gatewayStatus,
39
+ onRestartRequired = () => {},
40
+ onOpenGmailWebhook = () => {},
41
+ }) => {
42
+ const { accounts, loading, hasCompanyCredentials, refreshAccounts } =
43
+ useGoogleAccounts({ gatewayStatus });
36
44
  const [expandedAccountId, setExpandedAccountId] = useState("");
37
45
  const [scopesByAccountId, setScopesByAccountId] = useState({});
38
46
  const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({});
@@ -52,6 +60,21 @@ export const Google = ({ gatewayStatus }) => {
52
60
  const [addCompanyModalOpen, setAddCompanyModalOpen] = useState(false);
53
61
  const [savingAddCompany, setSavingAddCompany] = useState(false);
54
62
  const [disconnectAccountId, setDisconnectAccountId] = useState("");
63
+ const [gmailWizardState, setGmailWizardState] = useState({
64
+ visible: false,
65
+ accountId: "",
66
+ });
67
+ const {
68
+ loading: gmailLoading,
69
+ watchByAccountId,
70
+ clientConfigByClient,
71
+ busyByAccountId,
72
+ savingClient,
73
+ refresh: refreshGmailWatch,
74
+ saveClientSetup,
75
+ startWatchForAccount,
76
+ stopWatchForAccount,
77
+ } = useGmailWatch({ gatewayStatus, accounts });
55
78
 
56
79
  const hasPersonalAccount = useMemo(
57
80
  () => accounts.some((account) => isPersonalAccount(account)),
@@ -68,12 +91,16 @@ export const Google = ({ gatewayStatus }) => {
68
91
  );
69
92
 
70
93
  const ensureScopesForAccount = useCallback((account) => {
71
- const nextScopes = Array.isArray(account.activeScopes) && account.activeScopes.length
72
- ? account.activeScopes
73
- : Array.isArray(account.services) && account.services.length
74
- ? account.services
75
- : getDefaultScopes();
76
- setSavedScopesByAccountId((prev) => ({ ...prev, [account.id]: [...nextScopes] }));
94
+ const nextScopes =
95
+ Array.isArray(account.activeScopes) && account.activeScopes.length
96
+ ? account.activeScopes
97
+ : Array.isArray(account.services) && account.services.length
98
+ ? account.services
99
+ : getDefaultScopes();
100
+ setSavedScopesByAccountId((prev) => ({
101
+ ...prev,
102
+ [account.id]: [...nextScopes],
103
+ }));
77
104
  setScopesByAccountId((prev) => {
78
105
  const current = prev[account.id];
79
106
  if (!current || !hasScopesChanged(current, nextScopes)) {
@@ -103,7 +130,10 @@ export const Google = ({ gatewayStatus }) => {
103
130
  (accountId) => {
104
131
  const account = getAccountById(accountId);
105
132
  if (!account) return;
106
- const scopes = scopesByAccountId[accountId] || account.activeScopes || getDefaultScopes();
133
+ const scopes =
134
+ scopesByAccountId[accountId] ||
135
+ account.activeScopes ||
136
+ getDefaultScopes();
107
137
  if (!scopes.length) {
108
138
  window.alert("Select at least one service");
109
139
  return;
@@ -138,7 +168,10 @@ export const Google = ({ gatewayStatus }) => {
138
168
  try {
139
169
  const data = await checkGoogleApis(accountId);
140
170
  if (data.results) {
141
- setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results }));
171
+ setApiStatusByAccountId((prev) => ({
172
+ ...prev,
173
+ [accountId]: data.results,
174
+ }));
142
175
  }
143
176
  } finally {
144
177
  setCheckingByAccountId((prev) => {
@@ -157,16 +190,20 @@ export const Google = ({ gatewayStatus }) => {
157
190
  const accountId = String(event.data?.accountId || "").trim();
158
191
  setApiStatusByAccountId({});
159
192
  await refreshAccounts();
193
+ await refreshGmailWatch();
160
194
  if (accountId) {
161
195
  await handleCheckApis(accountId);
162
196
  }
163
197
  } else if (event.data?.google === "error") {
164
- showToast(`✗ Google auth failed: ${event.data.message || "unknown"}`, "error");
198
+ showToast(
199
+ `✗ Google auth failed: ${event.data.message || "unknown"}`,
200
+ "error",
201
+ );
165
202
  }
166
203
  };
167
204
  window.addEventListener("message", handler);
168
205
  return () => window.removeEventListener("message", handler);
169
- }, [handleCheckApis, refreshAccounts]);
206
+ }, [handleCheckApis, refreshAccounts, refreshGmailWatch]);
170
207
 
171
208
  useEffect(() => {
172
209
  if (!expandedAccountId) return;
@@ -197,6 +234,7 @@ export const Google = ({ gatewayStatus }) => {
197
234
  return next;
198
235
  });
199
236
  await refreshAccounts();
237
+ await refreshGmailWatch();
200
238
  };
201
239
 
202
240
  const openCredentialsModal = ({
@@ -225,6 +263,9 @@ export const Google = ({ gatewayStatus }) => {
225
263
  };
226
264
 
227
265
  const handleCredentialsSaved = async (account) => {
266
+ if (account?.id) {
267
+ setExpandedAccountId(account.id);
268
+ }
228
269
  await refreshAccounts();
229
270
  if (account?.id) startAuth(account.id);
230
271
  };
@@ -243,6 +284,9 @@ export const Google = ({ gatewayStatus }) => {
243
284
  return;
244
285
  }
245
286
  setAddCompanyModalOpen(false);
287
+ if (data.accountId) {
288
+ setExpandedAccountId(data.accountId);
289
+ }
246
290
  await refreshAccounts();
247
291
  if (data.accountId) startAuth(data.accountId);
248
292
  } finally {
@@ -276,40 +320,113 @@ export const Google = ({ gatewayStatus }) => {
276
320
  });
277
321
  };
278
322
 
279
- const handleEditCredentials = (accountId) => {
323
+ const handleEditCredentials = async (accountId) => {
280
324
  const account = getAccountById(accountId);
281
325
  if (!account) return;
282
326
  const personal = isPersonalAccount(account);
327
+ const client = personal ? "personal" : account.client || "default";
328
+ let credentialValues = {};
329
+ try {
330
+ const credentialResponse = await fetchGoogleCredentials({
331
+ accountId: account.id,
332
+ client,
333
+ });
334
+ if (credentialResponse?.ok) {
335
+ credentialValues = {
336
+ clientId: String(credentialResponse.clientId || ""),
337
+ clientSecret: String(credentialResponse.clientSecret || ""),
338
+ };
339
+ }
340
+ } catch {
341
+ showToast("Could not load saved client credentials", "warning");
342
+ }
283
343
  openCredentialsModal({
284
344
  accountId: account.id,
285
- client: personal ? "personal" : (account.client || "default"),
345
+ client,
286
346
  personal,
287
347
  title: `Edit Credentials (${account.email})`,
288
348
  submitLabel: "Save Credentials",
289
349
  defaultInstrType: personal ? "personal" : "workspace",
290
350
  initialValues: {
291
351
  email: account.email,
352
+ ...credentialValues,
292
353
  },
293
354
  });
294
355
  };
295
356
 
357
+ const openGmailSetupWizard = (accountId) => {
358
+ setGmailWizardState({
359
+ visible: true,
360
+ accountId: String(accountId || ""),
361
+ });
362
+ };
363
+
364
+ const closeGmailSetupWizard = () => {
365
+ setGmailWizardState({
366
+ visible: false,
367
+ accountId: "",
368
+ });
369
+ };
370
+
371
+ const handleEnableGmailWatch = async (accountId) => {
372
+ const account = getAccountById(accountId);
373
+ if (!account) return;
374
+ const client = String(account.client || "default").trim() || "default";
375
+ const clientConfig = clientConfigByClient.get(client);
376
+ if (!clientConfig?.configured) {
377
+ openGmailSetupWizard(accountId);
378
+ return;
379
+ }
380
+ try {
381
+ const result = await startWatchForAccount(accountId);
382
+ if (result?.restartRequired) {
383
+ onRestartRequired(true);
384
+ }
385
+ showToast("Gmail watch enabled", "success");
386
+ } catch (err) {
387
+ showToast(err.message || "Could not enable Gmail watch", "error");
388
+ }
389
+ };
390
+
391
+ const handleDisableGmailWatch = async (accountId) => {
392
+ try {
393
+ await stopWatchForAccount(accountId);
394
+ showToast("Gmail watch disabled", "info");
395
+ } catch (err) {
396
+ showToast(err.message || "Could not disable Gmail watch", "error");
397
+ }
398
+ };
399
+
400
+ const handleFinishGmailSetupWizard = async ({ client, projectId }) => {
401
+ const accountId = String(gmailWizardState.accountId || "").trim();
402
+ if (!accountId) return;
403
+ await saveClientSetup({
404
+ client,
405
+ projectId,
406
+ regeneratePushToken: false,
407
+ });
408
+ await startWatchForAccount(accountId);
409
+ showToast("Gmail setup complete and watch enabled", "success");
410
+ };
411
+
296
412
  const renderEmptyState = () => html`
297
- <div class="text-center space-y-2 py-1">
413
+ <div class="text-center space-y-2 pt-3">
298
414
  <div class="rounded-lg border border-border bg-black/20 px-3 py-5">
299
- <div class="flex flex-col items-center justify-center gap-1.5">
415
+ <div class="flex flex-col items-center justify-center gap-3">
300
416
  <img
301
417
  src=${kGoogleIconPath}
302
418
  alt="Google logo"
303
- class="h-4 w-4 shrink-0"
419
+ class="h-5 w-5 shrink-0"
304
420
  loading="lazy"
305
421
  decoding="async"
306
422
  />
307
423
  <p class="text-xs text-gray-500">
308
- Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet.
424
+ Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and
425
+ Meet.
309
426
  </p>
310
427
  </div>
311
428
  </div>
312
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
429
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 pt-2">
313
430
  <${ActionButton}
314
431
  onClick=${handleAddCompanyClick}
315
432
  tone="primary"
@@ -373,28 +490,46 @@ export const Google = ({ gatewayStatus }) => {
373
490
  `}
374
491
  />
375
492
  ${loading
376
- ? html`<div class="text-gray-500 text-sm text-center py-2">Loading...</div>`
493
+ ? html`<div class="text-gray-500 text-sm text-center py-2">
494
+ Loading...
495
+ </div>`
377
496
  : accounts.length
378
497
  ? html`
379
498
  <div class="space-y-2 mt-3">
380
- ${accounts.map((account) =>
381
- html`<${GoogleAccountRow}
382
- key=${account.id}
383
- account=${account}
384
- personal=${isPersonalAccount(account)}
385
- expanded=${expandedAccountId === account.id}
386
- onToggleExpanded=${(accountId) =>
387
- setExpandedAccountId((prev) => (prev === accountId ? "" : accountId))}
388
- scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()}
389
- savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()}
390
- apiStatus=${apiStatusByAccountId[account.id] || {}}
391
- checkingApis=${expandedAccountId === account.id && Boolean(checkingByAccountId[account.id])}
392
- onToggleScope=${handleToggleScope}
393
- onCheckApis=${handleCheckApis}
394
- onUpdatePermissions=${(accountId) => startAuth(accountId)}
395
- onEditCredentials=${handleEditCredentials}
396
- onDisconnect=${(accountId) => setDisconnectAccountId(accountId)}
397
- />`,
499
+ ${accounts.map(
500
+ (account) =>
501
+ html`<${GoogleAccountRow}
502
+ key=${account.id}
503
+ account=${account}
504
+ personal=${isPersonalAccount(account)}
505
+ expanded=${expandedAccountId === account.id}
506
+ onToggleExpanded=${(accountId) =>
507
+ setExpandedAccountId((prev) =>
508
+ prev === accountId ? "" : accountId,
509
+ )}
510
+ scopes=${scopesByAccountId[account.id] ||
511
+ account.activeScopes ||
512
+ getDefaultScopes()}
513
+ savedScopes=${savedScopesByAccountId[account.id] ||
514
+ account.activeScopes ||
515
+ getDefaultScopes()}
516
+ apiStatus=${apiStatusByAccountId[account.id] || {}}
517
+ checkingApis=${expandedAccountId === account.id &&
518
+ Boolean(checkingByAccountId[account.id])}
519
+ onToggleScope=${handleToggleScope}
520
+ onCheckApis=${handleCheckApis}
521
+ onUpdatePermissions=${(accountId) => startAuth(accountId)}
522
+ onEditCredentials=${handleEditCredentials}
523
+ onDisconnect=${(accountId) =>
524
+ setDisconnectAccountId(accountId)}
525
+ gmailWatchStatus=${watchByAccountId.get(account.id) ||
526
+ null}
527
+ gmailWatchBusy=${Boolean(busyByAccountId[account.id])}
528
+ onEnableGmailWatch=${handleEnableGmailWatch}
529
+ onDisableGmailWatch=${handleDisableGmailWatch}
530
+ onOpenGmailSetup=${openGmailSetupWizard}
531
+ onOpenGmailWebhook=${onOpenGmailWebhook}
532
+ />`,
398
533
  )}
399
534
  </div>
400
535
  `
@@ -422,6 +557,20 @@ export const Google = ({ gatewayStatus }) => {
422
557
  title="Add Company Account"
423
558
  />
424
559
 
560
+ <${GmailSetupWizard}
561
+ visible=${gmailWizardState.visible}
562
+ account=${getAccountById(gmailWizardState.accountId)}
563
+ clientConfig=${clientConfigByClient.get(
564
+ String(
565
+ getAccountById(gmailWizardState.accountId)?.client || "default",
566
+ ).trim() || "default",
567
+ ) || null}
568
+ saving=${savingClient || gmailLoading}
569
+ onClose=${closeGmailSetupWizard}
570
+ onSaveSetup=${saveClientSetup}
571
+ onFinish=${handleFinishGmailSetupWizard}
572
+ />
573
+
425
574
  <${ConfirmDialog}
426
575
  visible=${Boolean(disconnectAccountId)}
427
576
  title="Disconnect Google account?"
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchGmailConfig,
4
+ renewGmailWatch,
5
+ saveGmailConfig,
6
+ startGmailWatch,
7
+ stopGmailWatch,
8
+ } from "../../lib/api.js";
9
+
10
+ export const useGmailWatch = ({ gatewayStatus, accounts = [] }) => {
11
+ const [loading, setLoading] = useState(true);
12
+ const [config, setConfig] = useState(null);
13
+ const [busyByAccountId, setBusyByAccountId] = useState({});
14
+ const [savingClient, setSavingClient] = useState(false);
15
+
16
+ const refresh = useCallback(async () => {
17
+ setLoading(true);
18
+ try {
19
+ const nextConfig = await fetchGmailConfig();
20
+ setConfig(nextConfig);
21
+ return nextConfig;
22
+ } finally {
23
+ setLoading(false);
24
+ }
25
+ }, []);
26
+
27
+ useEffect(() => {
28
+ if (gatewayStatus !== "running") return;
29
+ refresh();
30
+ }, [gatewayStatus, refresh]);
31
+
32
+ useEffect(() => {
33
+ if (gatewayStatus !== "running") return;
34
+ if (!accounts.length) return;
35
+ refresh();
36
+ }, [accounts, gatewayStatus, refresh]);
37
+
38
+ const watchByAccountId = useMemo(() => {
39
+ const map = new Map();
40
+ for (const entry of config?.accounts || []) {
41
+ map.set(String(entry.accountId || ""), entry);
42
+ }
43
+ return map;
44
+ }, [config]);
45
+
46
+ const clientConfigByClient = useMemo(() => {
47
+ const map = new Map();
48
+ for (const clientConfig of config?.clients || []) {
49
+ map.set(String(clientConfig.client || "default"), clientConfig);
50
+ }
51
+ return map;
52
+ }, [config]);
53
+
54
+ const setBusy = (accountId, busy) => {
55
+ setBusyByAccountId((prev) => {
56
+ const key = String(accountId || "");
57
+ if (!key) return prev;
58
+ if (busy) return { ...prev, [key]: true };
59
+ if (!prev[key]) return prev;
60
+ const next = { ...prev };
61
+ delete next[key];
62
+ return next;
63
+ });
64
+ };
65
+
66
+ const startWatchForAccount = useCallback(async (accountId) => {
67
+ const key = String(accountId || "");
68
+ setBusy(key, true);
69
+ try {
70
+ const data = await startGmailWatch(key);
71
+ await refresh();
72
+ return data;
73
+ } finally {
74
+ setBusy(key, false);
75
+ }
76
+ }, [refresh]);
77
+
78
+ const stopWatchForAccount = useCallback(async (accountId) => {
79
+ const key = String(accountId || "");
80
+ setBusy(key, true);
81
+ try {
82
+ await stopGmailWatch(key);
83
+ await refresh();
84
+ } finally {
85
+ setBusy(key, false);
86
+ }
87
+ }, [refresh]);
88
+
89
+ const renewForAccount = useCallback(async (accountId = "") => {
90
+ const key = String(accountId || "");
91
+ if (key) setBusy(key, true);
92
+ try {
93
+ await renewGmailWatch({ accountId: key, force: true });
94
+ await refresh();
95
+ } finally {
96
+ if (key) setBusy(key, false);
97
+ }
98
+ }, [refresh]);
99
+
100
+ const saveClientSetup = useCallback(async ({
101
+ client = "default",
102
+ projectId = "",
103
+ regeneratePushToken = false,
104
+ } = {}) => {
105
+ setSavingClient(true);
106
+ try {
107
+ const data = await saveGmailConfig({
108
+ client,
109
+ projectId,
110
+ regeneratePushToken,
111
+ });
112
+ await refresh();
113
+ return data;
114
+ } catch (err) {
115
+ const message = String(err?.message || "");
116
+ if (message.toLowerCase().includes("not found")) {
117
+ throw new Error(
118
+ "Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded.",
119
+ );
120
+ }
121
+ throw err;
122
+ } finally {
123
+ setSavingClient(false);
124
+ }
125
+ }, [refresh]);
126
+
127
+ return {
128
+ loading,
129
+ config,
130
+ watchByAccountId,
131
+ clientConfigByClient,
132
+ busyByAccountId,
133
+ savingClient,
134
+ refresh,
135
+ saveClientSetup,
136
+ startWatchForAccount,
137
+ stopWatchForAccount,
138
+ renewForAccount,
139
+ };
140
+ };
@@ -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) => {
@@ -12,7 +12,7 @@ const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx";
12
12
  const kBrowsePanelMinHeightPx = 120;
13
13
  const kBrowseBottomMinHeightPx = 120;
14
14
  const kBrowseResizerHeightPx = 6;
15
- const kDefaultBrowseBottomPanelHeightPx = 160;
15
+ const kDefaultBrowseBottomPanelHeightPx = 260;
16
16
 
17
17
  const readStoredBrowseBottomPanelHeight = () => {
18
18
  try {
@@ -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
  );
@@ -116,7 +116,7 @@ export const CreateGroupStep = ({ onNext, onBack }) => html`
116
116
  <p class="text-xs font-medium text-gray-300">Create the group</p>
117
117
  <ol class="text-xs text-gray-400 space-y-2 list-decimal list-inside">
118
118
  <li>
119
- Open Telegram and create a
119
+ Open Telegram and create a${" "}
120
120
  <span class="text-gray-300">new group</span>
121
121
  </li>
122
122
  <li>
@@ -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
+ };