@chrysb/alphaclaw 0.7.1 → 0.7.2-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.
@@ -1158,6 +1158,18 @@
1158
1158
  background: rgba(255, 255, 255, 0.04);
1159
1159
  }
1160
1160
 
1161
+ .release-notes-preview {
1162
+ padding: 8px 12px 24px 14px;
1163
+ }
1164
+
1165
+ .release-notes-preview > :first-child {
1166
+ margin-top: 0;
1167
+ }
1168
+
1169
+ .release-notes-preview > :last-child {
1170
+ margin-bottom: 0;
1171
+ }
1172
+
1161
1173
  .file-viewer-editor-highlight-line-content .hl-comment {
1162
1174
  color: var(--comment);
1163
1175
  font-style: italic;
@@ -16,7 +16,7 @@ import { ErrorWarningLineIcon } from "../icons.js";
16
16
  const html = htm.bind(h);
17
17
  const kRecentRunFetchLimit = 100;
18
18
  const kRecentRunRowsLimit = 20;
19
- const kRecentRunCollapseThreshold = 5;
19
+ const kRecentRunCollapseThreshold = 2;
20
20
  const kTrendRange24h = "24h";
21
21
  const kTrendRange7d = "7d";
22
22
  const kTrendRange30d = "30d";
@@ -107,6 +107,7 @@ const buildCollapsedRunRows = (recentRuns = []) => {
107
107
  newestTs: Number(streak[0]?.ts || 0),
108
108
  oldestTs: Number(streak[streak.length - 1]?.ts || 0),
109
109
  statusCounts,
110
+ entries: streak,
110
111
  });
111
112
  index = streakEnd;
112
113
  continue;
@@ -41,11 +41,69 @@ const formatRowTimestamp = (timestampMs, variant = "overview") =>
41
41
  variant === "detail"
42
42
  ? formatDetailTimestamp(timestampMs)
43
43
  : formatOverviewTimestamp(timestampMs);
44
+ const renderEntrySummaryRow = ({ runEntry = {}, variant = "overview" }) => {
45
+ const runStatus = String(runEntry?.status || "unknown");
46
+ const runTokens = getCronRunTotalTokens(runEntry);
47
+ const runEstimatedCost = getCronRunEstimatedCost(runEntry);
48
+ const runTitle = String(runEntry?.jobName || "").trim();
49
+ const hasRunTitle = runTitle.length > 0;
50
+ const isDetail = variant === "detail";
51
+ return html`
52
+ <div class="ac-history-summary-row">
53
+ <span class="inline-flex items-center gap-2 min-w-0">
54
+ ${isDetail
55
+ ? html`
56
+ <span class="truncate text-xs text-gray-300">
57
+ ${formatRowTimestamp(runEntry.ts, variant)}
58
+ </span>
59
+ `
60
+ : hasRunTitle
61
+ ? html`
62
+ <span class="inline-flex items-center gap-2 min-w-0">
63
+ <span class="truncate text-xs text-gray-300">${runTitle}</span>
64
+ <span class="text-xs text-gray-500 shrink-0">
65
+ ${formatRowTimestamp(runEntry.ts, variant)}
66
+ </span>
67
+ </span>
68
+ `
69
+ : html`
70
+ <span class="truncate text-xs text-gray-300">
71
+ ${runEntry.jobId} - ${formatRowTimestamp(runEntry.ts, variant)}
72
+ </span>
73
+ `}
74
+ </span>
75
+ <span class="inline-flex items-center gap-3 shrink-0 text-xs">
76
+ <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
77
+ <span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
78
+ <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
79
+ ${isDetail
80
+ ? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
81
+ : html`
82
+ <span class="text-gray-500">
83
+ ${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
84
+ </span>
85
+ `}
86
+ </span>
87
+ </div>
88
+ `;
89
+ };
90
+ const getCollapsedGroupAggregates = (entries = []) =>
91
+ entries.reduce(
92
+ (accumulator, runEntry) => {
93
+ accumulator.totalTokens += getCronRunTotalTokens(runEntry);
94
+ const estimatedCost = getCronRunEstimatedCost(runEntry);
95
+ if (estimatedCost != null) {
96
+ accumulator.totalCost += estimatedCost;
97
+ accumulator.hasAnyCost = true;
98
+ }
99
+ return accumulator;
100
+ },
101
+ { totalTokens: 0, totalCost: 0, hasAnyCost: false },
102
+ );
44
103
  const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
45
- const statusSummary = Object.entries(row.statusCounts || {})
46
- .map(([status, count]) => `${status}: ${count}`)
47
- .join(" ");
48
- const timeRangeLabel = `[${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}]`;
104
+ const entries = Array.isArray(row?.entries) ? row.entries : [];
105
+ const { totalTokens, totalCost, hasAnyCost } = getCollapsedGroupAggregates(entries);
106
+ const timeRangeLabel = `${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}`;
49
107
  return html`
50
108
  <details
51
109
  key=${`collapsed:${rowIndex}:${row.jobId}`}
@@ -55,19 +113,40 @@ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
55
113
  <div class="ac-history-summary-row">
56
114
  <span class="inline-flex items-center gap-2 min-w-0">
57
115
  <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
58
- <span class="truncate text-xs text-gray-300">
59
- ${row.jobName} - ${formatTokenCount(row.count)} runs -
60
- ${timeRangeLabel}
116
+ <span class="inline-flex items-center gap-2 min-w-0">
117
+ <span class="truncate text-xs text-gray-300">
118
+ ${row.jobName} - ${formatTokenCount(row.count)} runs
119
+ </span>
120
+ <span class="text-xs text-gray-500 shrink-0">${timeRangeLabel}</span>
121
+ </span>
122
+ </span>
123
+ <span class="inline-flex items-center gap-3 shrink-0 text-xs">
124
+ <span class="text-gray-400">${formatTokenCount(totalTokens)} tk</span>
125
+ <span class="text-gray-500">
126
+ ${hasAnyCost ? `~${formatCost(totalCost)}` : "—"}
61
127
  </span>
62
128
  </span>
63
129
  </div>
64
130
  </summary>
65
131
  <div class="ac-history-body space-y-2 text-xs">
66
- <div class="text-gray-500">
67
- ${formatTokenCount(row.count)} consecutive runs collapsed
68
- (${timeRangeLabel})
69
- </div>
70
- <div class="text-gray-500">Statuses: ${statusSummary}</div>
132
+ ${entries.length > 0
133
+ ? html`
134
+ <div class="ac-history-list">
135
+ ${entries.map((runEntry, entryIndex) =>
136
+ renderEntryRow({
137
+ row: {
138
+ type: "entry",
139
+ entry: runEntry,
140
+ },
141
+ rowIndex: `${rowIndex}:${entryIndex}`,
142
+ variant: "overview",
143
+ onSelectJob,
144
+ showOpenJobButton: false,
145
+ }),
146
+ )}
147
+ </div>
148
+ `
149
+ : null}
71
150
  ${row?.jobId
72
151
  ? html`
73
152
  <div>
@@ -94,7 +173,6 @@ const renderEntryRow = ({
94
173
  }) => {
95
174
  const runEntry = row?.entry || row || {};
96
175
  const runUsage = runEntry?.usage || {};
97
- const runStatus = String(runEntry?.status || "unknown");
98
176
  const runInputTokens = Number(
99
177
  runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
100
178
  );
@@ -103,60 +181,17 @@ const renderEntryRow = ({
103
181
  );
104
182
  const runTokens = getCronRunTotalTokens(runEntry);
105
183
  const runEstimatedCost = getCronRunEstimatedCost(runEntry);
106
- const runTitle = String(runEntry?.jobName || "").trim();
107
- const hasRunTitle = runTitle.length > 0;
108
- const isDetail = variant === "detail";
109
184
  return html`
110
185
  <details
111
186
  key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
112
187
  class="ac-history-item"
113
188
  >
114
189
  <summary class="ac-history-summary">
115
- <div class="ac-history-summary-row">
116
- <span class="inline-flex items-center gap-2 min-w-0">
117
- <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
118
- ${isDetail
119
- ? html`
120
- <span class="truncate text-xs text-gray-300">
121
- ${formatRowTimestamp(runEntry.ts, variant)}
122
- </span>
123
- `
124
- : hasRunTitle
125
- ? html`
126
- <span class="inline-flex items-center gap-2 min-w-0">
127
- <span class="truncate text-xs text-gray-300"
128
- >${runTitle}</span
129
- >
130
- <span class="text-xs text-gray-500 shrink-0">
131
- ${formatRowTimestamp(runEntry.ts, variant)}
132
- </span>
133
- </span>
134
- `
135
- : html`
136
- <span class="truncate text-xs text-gray-300">
137
- ${runEntry.jobId} -
138
- ${formatRowTimestamp(runEntry.ts, variant)}
139
- </span>
140
- `}
141
- </span>
142
- <span class="inline-flex items-center gap-3 shrink-0 text-xs">
143
- <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
144
- <span class="text-gray-400"
145
- >${formatDurationCompactMs(runEntry.durationMs)}</span
146
- >
147
- <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
148
- ${isDetail
149
- ? html`<span class="text-gray-500"
150
- >${runDeliveryLabel(runEntry)}</span
151
- >`
152
- : html`
153
- <span class="text-gray-500"
154
- >${runEstimatedCost == null
155
- ? "—"
156
- : `~${formatCost(runEstimatedCost)}`}</span
157
- >
158
- `}
159
- </span>
190
+ <div class="inline-flex items-center gap-2 min-w-0 w-full">
191
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
192
+ <div class="min-w-0 flex-1">
193
+ ${renderEntrySummaryRow({ runEntry, variant })}
194
+ </div>
160
195
  </div>
161
196
  </summary>
162
197
  <div class="ac-history-body space-y-2 text-xs">
@@ -6,6 +6,7 @@ import { FileTree } from "./file-tree.js";
6
6
  import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
7
7
  import { UpdateActionButton } from "./update-action-button.js";
8
8
  import { SidebarGitPanel } from "./sidebar-git-panel.js";
9
+ import { UpdateModal } from "./update-modal.js";
9
10
  import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
10
11
 
11
12
  const html = htm.bind(h);
@@ -62,6 +63,7 @@ export const AppSidebar = ({
62
63
  readStoredBrowseBottomPanelHeight,
63
64
  );
64
65
  const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false);
66
+ const [updateModalOpen, setUpdateModalOpen] = useState(false);
65
67
 
66
68
  useEffect(() => {
67
69
  const settings = readUiSettings();
@@ -237,10 +239,10 @@ export const AppSidebar = ({
237
239
  `,
238
240
  )}
239
241
  <div class="sidebar-footer">
240
- ${acHasUpdate && acLatest && selectedNavId === "general"
242
+ ${acHasUpdate && acLatest
241
243
  ? html`
242
244
  <${UpdateActionButton}
243
- onClick=${onAcUpdate}
245
+ onClick=${() => setUpdateModalOpen(true)}
244
246
  loading=${acUpdating}
245
247
  warning=${true}
246
248
  idleLabel=${`Update to v${acLatest}`}
@@ -292,6 +294,16 @@ export const AppSidebar = ({
292
294
  </div>
293
295
  </div>
294
296
  </div>
297
+ <${UpdateModal}
298
+ visible=${updateModalOpen}
299
+ onClose=${() => {
300
+ if (acUpdating) return;
301
+ setUpdateModalOpen(false);
302
+ }}
303
+ version=${acLatest}
304
+ onUpdate=${onAcUpdate}
305
+ updating=${acUpdating}
306
+ />
295
307
  </div>
296
308
  `;
297
309
  };
@@ -0,0 +1,173 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { marked } from "https://esm.sh/marked";
5
+ import { fetchAlphaclawReleaseNotes } from "../lib/api.js";
6
+ import { ModalShell } from "./modal-shell.js";
7
+ import { ActionButton } from "./action-button.js";
8
+ import { LoadingSpinner } from "./loading-spinner.js";
9
+ import { CloseIcon } from "./icons.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ const getReleaseTagFromVersion = (version) => {
14
+ const rawVersion = String(version || "").trim();
15
+ if (!rawVersion) return "";
16
+ return rawVersion.startsWith("v") ? rawVersion : `v${rawVersion}`;
17
+ };
18
+
19
+ const formatPublishedAt = (value) => {
20
+ const dateMs = Date.parse(String(value || ""));
21
+ if (!Number.isFinite(dateMs)) return "";
22
+ try {
23
+ return new Intl.DateTimeFormat(undefined, {
24
+ dateStyle: "medium",
25
+ timeStyle: "short",
26
+ }).format(new Date(dateMs));
27
+ } catch {
28
+ return "";
29
+ }
30
+ };
31
+
32
+ const getReleaseUrl = (tag) =>
33
+ tag
34
+ ? `https://github.com/chrysb/alphaclaw/releases/tag/${encodeURIComponent(tag)}`
35
+ : "https://github.com/chrysb/alphaclaw/releases";
36
+
37
+ export const UpdateModal = ({
38
+ visible = false,
39
+ onClose = () => {},
40
+ version = "",
41
+ onUpdate = () => {},
42
+ updating = false,
43
+ }) => {
44
+ const requestedTag = useMemo(() => getReleaseTagFromVersion(version), [version]);
45
+ const [loadingNotes, setLoadingNotes] = useState(false);
46
+ const [notesError, setNotesError] = useState("");
47
+ const [notesData, setNotesData] = useState(null);
48
+
49
+ useEffect(() => {
50
+ if (!visible) return;
51
+ let isActive = true;
52
+ const loadNotes = async () => {
53
+ setLoadingNotes(true);
54
+ setNotesError("");
55
+ try {
56
+ const data = await fetchAlphaclawReleaseNotes(requestedTag);
57
+ if (!isActive) return;
58
+ if (!data?.ok) {
59
+ setNotesError(data?.error || "Could not load release notes");
60
+ setNotesData(null);
61
+ return;
62
+ }
63
+ setNotesData(data);
64
+ } catch (err) {
65
+ if (!isActive) return;
66
+ setNotesError(err?.message || "Could not load release notes");
67
+ setNotesData(null);
68
+ } finally {
69
+ if (!isActive) return;
70
+ setLoadingNotes(false);
71
+ }
72
+ };
73
+ loadNotes();
74
+ return () => {
75
+ isActive = false;
76
+ };
77
+ }, [visible, requestedTag]);
78
+
79
+ const effectiveTag = String(notesData?.tag || requestedTag || "").trim();
80
+ const effectiveReleaseUrl =
81
+ String(notesData?.htmlUrl || "").trim() || getReleaseUrl(effectiveTag);
82
+ const updateLabel = effectiveTag ? `Update to ${effectiveTag}` : "Update now";
83
+ const publishedAtLabel = formatPublishedAt(notesData?.publishedAt);
84
+ const releaseBody = String(notesData?.body || "").trim();
85
+ const releasePreviewHtml = useMemo(
86
+ () =>
87
+ marked.parse(releaseBody, {
88
+ gfm: true,
89
+ breaks: true,
90
+ }),
91
+ [releaseBody],
92
+ );
93
+
94
+ return html`
95
+ <${ModalShell}
96
+ visible=${visible}
97
+ onClose=${onClose}
98
+ panelClassName="relative bg-modal border border-border rounded-xl p-5 w-full max-w-3xl max-h-[92vh] overflow-hidden flex flex-col gap-4"
99
+ >
100
+ <button
101
+ type="button"
102
+ onclick=${onClose}
103
+ class="absolute top-5 right-5 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
104
+ aria-label="Close modal"
105
+ >
106
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
107
+ </button>
108
+ <div class="space-y-1 pr-10">
109
+ <h3 class="text-sm font-semibold">AlphaClaw release notes</h3>
110
+ ${publishedAtLabel
111
+ ? html`<p class="text-xs text-gray-500">Published ${publishedAtLabel}</p>`
112
+ : null}
113
+ </div>
114
+ <div class="ac-surface-inset border border-border rounded-lg p-2 overflow-auto min-h-[220px] max-h-[66vh]">
115
+ ${loadingNotes
116
+ ? html`
117
+ <div class="min-h-[200px] flex items-center justify-center text-gray-400">
118
+ <span class="inline-flex items-center gap-2 text-sm">
119
+ <${LoadingSpinner} className="h-4 w-4" />
120
+ Loading release notes...
121
+ </span>
122
+ </div>
123
+ `
124
+ : notesError
125
+ ? html`
126
+ <div class="space-y-2">
127
+ <p class="text-sm text-red-300">${notesError}</p>
128
+ <a
129
+ class="ac-tip-link text-xs"
130
+ href=${effectiveReleaseUrl}
131
+ target="_blank"
132
+ rel="noreferrer"
133
+ >View release on GitHub</a
134
+ >
135
+ </div>
136
+ `
137
+ : releaseBody
138
+ ? html`<div
139
+ class="file-viewer-preview release-notes-preview"
140
+ dangerouslySetInnerHTML=${{ __html: releasePreviewHtml }}
141
+ ></div>`
142
+ : html`
143
+ <div class="space-y-2">
144
+ <p class="text-sm text-gray-300">No release notes were published for this tag.</p>
145
+ <a
146
+ class="ac-tip-link text-xs"
147
+ href=${effectiveReleaseUrl}
148
+ target="_blank"
149
+ rel="noreferrer"
150
+ >Open release on GitHub</a
151
+ >
152
+ </div>
153
+ `}
154
+ </div>
155
+ <div class="flex items-center justify-end gap-2 pt-1">
156
+ <${ActionButton}
157
+ onClick=${onClose}
158
+ tone="ghost"
159
+ idleLabel="Later"
160
+ disabled=${updating}
161
+ />
162
+ <${ActionButton}
163
+ onClick=${onUpdate}
164
+ tone="warning"
165
+ idleLabel=${updateLabel}
166
+ loadingLabel="Updating..."
167
+ loading=${updating}
168
+ disabled=${loadingNotes}
169
+ />
170
+ </div>
171
+ </${ModalShell}>
172
+ `;
173
+ };
@@ -26,6 +26,14 @@ export const kRangeOptions = [
26
26
 
27
27
  export const kDefaultUsageDays = 30;
28
28
  export const kDefaultUsageMetric = "tokens";
29
+ export const kDefaultUsageBreakdown = "model";
29
30
  export const kUsageDaysUiSettingKey = "usageDays";
30
31
  export const kUsageMetricUiSettingKey = "usageMetric";
32
+ export const kUsageBreakdownUiSettingKey = "usageBreakdown";
31
33
  export const kUsageSourceOrder = ["chat", "hooks", "cron"];
34
+
35
+ export const kUsageBreakdownOptions = [
36
+ { label: "Model breakdown", value: "model" },
37
+ { label: "Type breakdown", value: "source" },
38
+ { label: "Agent breakdown", value: "agent" },
39
+ ];
@@ -22,3 +22,16 @@ export const renderSourceLabel = (source) => {
22
22
  if (source === "cron") return "Cron";
23
23
  return "Chat";
24
24
  };
25
+
26
+ export const renderBreakdownLabel = (value, breakdown) => {
27
+ const normalizedBreakdown = String(breakdown || "model");
28
+ const raw = String(value || "").trim();
29
+ if (!raw) return "Unknown";
30
+ if (normalizedBreakdown === "source") {
31
+ return renderSourceLabel(raw);
32
+ }
33
+ if (normalizedBreakdown === "agent") {
34
+ return raw === "unknown" ? "Unknown agent" : raw;
35
+ }
36
+ return raw;
37
+ };
@@ -53,10 +53,12 @@ export const UsageTab = ({ sessionId = "" }) => {
53
53
  summary=${state.summary}
54
54
  periodSummary=${state.periodSummary}
55
55
  metric=${state.metric}
56
+ breakdown=${state.breakdown}
56
57
  days=${state.days}
57
58
  overviewCanvasRef=${state.overviewCanvasRef}
58
59
  onDaysChange=${actions.setDays}
59
60
  onMetricChange=${actions.setMetric}
61
+ onBreakdownChange=${actions.setBreakdown}
60
62
  />
61
63
  `}
62
64
  <${SessionsSection}
@@ -7,7 +7,11 @@ import {
7
7
  formatUsd,
8
8
  } from "../../lib/format.js";
9
9
  import { SegmentedControl } from "../segmented-control.js";
10
- import { kRangeOptions, kUsageSourceOrder } from "./constants.js";
10
+ import {
11
+ kRangeOptions,
12
+ kUsageBreakdownOptions,
13
+ kUsageSourceOrder,
14
+ } from "./constants.js";
11
15
  import { renderSourceLabel } from "./formatters.js";
12
16
 
13
17
  const html = htm.bind(h);
@@ -215,10 +219,12 @@ export const OverviewSection = ({
215
219
  summary = null,
216
220
  periodSummary,
217
221
  metric = "tokens",
222
+ breakdown = "model",
218
223
  days = 30,
219
224
  overviewCanvasRef,
220
225
  onDaysChange = () => {},
221
226
  onMetricChange = () => {},
227
+ onBreakdownChange = () => {},
222
228
  }) => {
223
229
  const overviewMetrics = getOverviewMetrics(summary);
224
230
 
@@ -264,9 +270,21 @@ export const OverviewSection = ({
264
270
  <div
265
271
  class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
266
272
  >
267
- <h2 class="card-label text-xs">
268
- Daily ${metric === "tokens" ? "tokens" : "cost"} by model
269
- </h2>
273
+ <label class="inline-flex items-center gap-2">
274
+ <select
275
+ class="bg-black/30 border border-border rounded-lg text-xs px-2.5 py-1.5 text-gray-200 focus:border-gray-500"
276
+ value=${breakdown}
277
+ onChange=${(event) =>
278
+ onBreakdownChange(String(event.currentTarget?.value || "model"))}
279
+ aria-label="Usage chart breakdown"
280
+ >
281
+ ${kUsageBreakdownOptions.map(
282
+ (option) => html`
283
+ <option value=${option.value}>${option.label}</option>
284
+ `,
285
+ )}
286
+ </select>
287
+ </label>
270
288
  <div class="flex items-center gap-2">
271
289
  <${SegmentedControl}
272
290
  options=${kRangeOptions.map((option) => ({
@@ -13,12 +13,14 @@ import {
13
13
  import { formatInteger, formatUsd } from "../../lib/format.js";
14
14
  import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
15
15
  import {
16
+ kDefaultUsageBreakdown,
16
17
  kDefaultUsageDays,
17
18
  kDefaultUsageMetric,
19
+ kUsageBreakdownUiSettingKey,
18
20
  kUsageDaysUiSettingKey,
19
21
  kUsageMetricUiSettingKey,
20
22
  } from "./constants.js";
21
- import { toChartColor, toLocalDayKey } from "./formatters.js";
23
+ import { renderBreakdownLabel, toChartColor, toLocalDayKey } from "./formatters.js";
22
24
 
23
25
  export const useUsageTab = ({ sessionId = "" }) => {
24
26
  const [days, setDays] = useState(() => {
@@ -35,6 +37,13 @@ export const useUsageTab = ({ sessionId = "" }) => {
35
37
  ? "cost"
36
38
  : kDefaultUsageMetric;
37
39
  });
40
+ const [breakdown, setBreakdown] = useState(() => {
41
+ const settings = readUiSettings();
42
+ const configured = String(settings[kUsageBreakdownUiSettingKey] || "").trim();
43
+ return configured === "source" || configured === "agent"
44
+ ? configured
45
+ : kDefaultUsageBreakdown;
46
+ });
38
47
  const [summary, setSummary] = useState(null);
39
48
  const [sessions, setSessions] = useState([]);
40
49
  const [sessionDetailById, setSessionDetailById] = useState({});
@@ -104,8 +113,9 @@ export const useUsageTab = ({ sessionId = "" }) => {
104
113
  const settings = readUiSettings();
105
114
  settings[kUsageDaysUiSettingKey] = days;
106
115
  settings[kUsageMetricUiSettingKey] = metric;
116
+ settings[kUsageBreakdownUiSettingKey] = breakdown;
107
117
  writeUiSettings(settings);
108
- }, [days, metric]);
118
+ }, [days, metric, breakdown]);
109
119
 
110
120
  useEffect(() => {
111
121
  loadSessions();
@@ -166,28 +176,52 @@ export const useUsageTab = ({ sessionId = "" }) => {
166
176
 
167
177
  const overviewDatasets = useMemo(() => {
168
178
  const rows = Array.isArray(summary?.daily) ? summary.daily : [];
169
- const allModels = new Set();
179
+ const allBreakdownKeys = new Set();
180
+ const totalsByBreakdownKey = new Map();
181
+ const breakdownRowKey =
182
+ breakdown === "source" ? "sources" : breakdown === "agent" ? "agents" : "models";
183
+ const breakdownValueKey =
184
+ breakdown === "source" ? "source" : breakdown === "agent" ? "agent" : "model";
170
185
  for (const dayRow of rows) {
171
- for (const modelRow of dayRow.models || []) {
172
- allModels.add(String(modelRow.model || "unknown"));
186
+ for (const breakdownRow of dayRow[breakdownRowKey] || []) {
187
+ const bucketKey = String(breakdownRow[breakdownValueKey] || "unknown");
188
+ allBreakdownKeys.add(bucketKey);
189
+ totalsByBreakdownKey.set(
190
+ bucketKey,
191
+ Number(totalsByBreakdownKey.get(bucketKey) || 0) +
192
+ Number(
193
+ metric === "cost"
194
+ ? breakdownRow.totalCost || 0
195
+ : breakdownRow.totalTokens || 0,
196
+ ),
197
+ );
173
198
  }
174
199
  }
175
200
  const labels = rows.map((row) => String(row.date || ""));
176
- const datasets = Array.from(allModels).map((model) => ({
177
- label: model,
201
+ const orderedBreakdownKeys = Array.from(allBreakdownKeys).sort(
202
+ (leftValue, rightValue) => {
203
+ const leftTotal = Number(totalsByBreakdownKey.get(leftValue) || 0);
204
+ const rightTotal = Number(totalsByBreakdownKey.get(rightValue) || 0);
205
+ if (rightTotal !== leftTotal) return rightTotal - leftTotal;
206
+ return leftValue.localeCompare(rightValue);
207
+ },
208
+ );
209
+ const datasets = orderedBreakdownKeys.map((bucketKey) => ({
210
+ label: bucketKey,
178
211
  data: rows.map((row) => {
179
- const found = (row.models || []).find(
180
- (m) => String(m.model || "") === model,
212
+ const found = (row[breakdownRowKey] || []).find(
213
+ (breakdownRow) =>
214
+ String(breakdownRow[breakdownValueKey] || "") === bucketKey,
181
215
  );
182
216
  if (!found) return 0;
183
217
  return metric === "cost"
184
218
  ? Number(found.totalCost || 0)
185
219
  : Number(found.totalTokens || 0);
186
220
  }),
187
- backgroundColor: toChartColor(model),
221
+ backgroundColor: toChartColor(`${breakdown}:${bucketKey}`),
188
222
  }));
189
223
  return { labels, datasets };
190
- }, [summary, metric]);
224
+ }, [summary, metric, breakdown]);
191
225
 
192
226
  useEffect(() => {
193
227
  const canvas = overviewCanvasRef.current;
@@ -229,9 +263,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
229
263
  callbacks: {
230
264
  label: (context) => {
231
265
  const value = Number(context.parsed.y || 0);
266
+ const label = renderBreakdownLabel(context.dataset.label, breakdown);
232
267
  return metric === "cost"
233
- ? `${context.dataset.label}: ${formatUsd(value)}`
234
- : `${context.dataset.label}: ${formatInteger(value)} tokens`;
268
+ ? `${label}: ${formatUsd(value)}`
269
+ : `${label}: ${formatInteger(value)} tokens`;
235
270
  },
236
271
  },
237
272
  },
@@ -244,12 +279,13 @@ export const useUsageTab = ({ sessionId = "" }) => {
244
279
  overviewChartRef.current = null;
245
280
  }
246
281
  };
247
- }, [overviewDatasets, metric]);
282
+ }, [overviewDatasets, metric, breakdown]);
248
283
 
249
284
  return {
250
285
  state: {
251
286
  days,
252
287
  metric,
288
+ breakdown,
253
289
  summary,
254
290
  sessions,
255
291
  sessionDetailById,
@@ -264,6 +300,7 @@ export const useUsageTab = ({ sessionId = "" }) => {
264
300
  actions: {
265
301
  setDays,
266
302
  setMetric,
303
+ setBreakdown,
267
304
  loadSummary,
268
305
  loadSessionDetail,
269
306
  setExpandedSessionIds,
@@ -450,6 +450,42 @@ export async function fetchAlphaclawVersion(refresh = false) {
450
450
  return res.json();
451
451
  }
452
452
 
453
+ export async function fetchAlphaclawReleaseNotes(tag = "") {
454
+ const normalizedTag = String(tag || "").trim();
455
+ const query = normalizedTag
456
+ ? `?${new URLSearchParams({ tag: normalizedTag }).toString()}`
457
+ : "";
458
+ try {
459
+ const res = await authFetch(`/api/alphaclaw/release-notes${query}`);
460
+ return await parseJsonOrThrow(res, "Could not load release notes");
461
+ } catch {
462
+ const endpoint = normalizedTag
463
+ ? `https://api.github.com/repos/chrysb/alphaclaw/releases/tags/${encodeURIComponent(normalizedTag)}`
464
+ : "https://api.github.com/repos/chrysb/alphaclaw/releases/latest";
465
+ const res = await fetch(endpoint, {
466
+ headers: { Accept: "application/vnd.github+json" },
467
+ });
468
+ const text = await res.text();
469
+ let data = null;
470
+ try {
471
+ data = text ? JSON.parse(text) : null;
472
+ } catch {
473
+ throw new Error(text || "Could not load release notes");
474
+ }
475
+ if (!res.ok) {
476
+ throw new Error(data?.message || text || "Could not load release notes");
477
+ }
478
+ return {
479
+ ok: true,
480
+ tag: String(data?.tag_name || normalizedTag || ""),
481
+ name: String(data?.name || ""),
482
+ body: String(data?.body || ""),
483
+ htmlUrl: String(data?.html_url || ""),
484
+ publishedAt: String(data?.published_at || ""),
485
+ };
486
+ }
487
+ }
488
+
453
489
  export async function updateAlphaclaw() {
454
490
  const res = await authFetch("/api/alphaclaw/update", { method: "POST" });
455
491
  return res.json();
@@ -137,6 +137,8 @@ const kVersionCacheTtlMs = 60 * 1000;
137
137
  const kLatestVersionCacheTtlMs = 10 * 60 * 1000;
138
138
  const kOpenclawRegistryUrl = "https://registry.npmjs.org/openclaw";
139
139
  const kAlphaclawRegistryUrl = "https://registry.npmjs.org/@chrysb%2falphaclaw";
140
+ const kAlphaclawGithubReleasesBaseUrl =
141
+ "https://api.github.com/repos/chrysb/alphaclaw/releases";
140
142
  const kAppDir = kNpmPackageRoot;
141
143
  const kMaxPayloadBytes = parsePositiveInt(process.env.WEBHOOK_LOG_MAX_BYTES, 50 * 1024);
142
144
  const kWebhookPruneDays = parsePositiveInt(process.env.WEBHOOK_LOG_RETENTION_DAYS, 30);
@@ -424,6 +426,7 @@ module.exports = {
424
426
  kLatestVersionCacheTtlMs,
425
427
  kOpenclawRegistryUrl,
426
428
  kAlphaclawRegistryUrl,
429
+ kAlphaclawGithubReleasesBaseUrl,
427
430
  kAppDir,
428
431
  kMaxPayloadBytes,
429
432
  kWebhookPruneDays,
@@ -153,12 +153,16 @@ const getDailySummary = ({
153
153
  `)
154
154
  .all({ $lookbackMs: lookbackMs });
155
155
  const byDateModel = new Map();
156
+ const byDateSource = new Map();
157
+ const byDateAgent = new Map();
156
158
  for (const eventRow of eventsRows) {
157
159
  const timestamp = coerceInt(eventRow.timestamp);
158
160
  const dayKey = normalizedTimeZone === kUtcTimeZone
159
161
  ? toDayKey(timestamp)
160
162
  : toTimeZoneDayKey(timestamp, normalizedTimeZone);
161
163
  if (dayKey < startDay) continue;
164
+ const sessionRef = String(eventRow.session_key || eventRow.session_id || "");
165
+ const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);
162
166
  const model = String(eventRow.model || "unknown");
163
167
  const mapKey = `${dayKey}\u0000${model}`;
164
168
  if (!byDateModel.has(mapKey)) {
@@ -197,6 +201,52 @@ const getDailySummary = ({
197
201
  if (!aggregate.provider && eventRow.provider) {
198
202
  aggregate.provider = String(eventRow.provider || "unknown");
199
203
  }
204
+
205
+ const sourceMapKey = `${dayKey}\u0000${source}`;
206
+ if (!byDateSource.has(sourceMapKey)) {
207
+ byDateSource.set(sourceMapKey, {
208
+ source,
209
+ date: dayKey,
210
+ inputTokens: 0,
211
+ outputTokens: 0,
212
+ cacheReadTokens: 0,
213
+ cacheWriteTokens: 0,
214
+ totalTokens: 0,
215
+ turnCount: 0,
216
+ totalCost: 0,
217
+ });
218
+ }
219
+ const sourceAggregate = byDateSource.get(sourceMapKey);
220
+ sourceAggregate.inputTokens += metrics.inputTokens;
221
+ sourceAggregate.outputTokens += metrics.outputTokens;
222
+ sourceAggregate.cacheReadTokens += metrics.cacheReadTokens;
223
+ sourceAggregate.cacheWriteTokens += metrics.cacheWriteTokens;
224
+ sourceAggregate.totalTokens += metrics.totalTokens;
225
+ sourceAggregate.turnCount += 1;
226
+ sourceAggregate.totalCost += metrics.totalCost;
227
+
228
+ const agentMapKey = `${dayKey}\u0000${agent}`;
229
+ if (!byDateAgent.has(agentMapKey)) {
230
+ byDateAgent.set(agentMapKey, {
231
+ agent,
232
+ date: dayKey,
233
+ inputTokens: 0,
234
+ outputTokens: 0,
235
+ cacheReadTokens: 0,
236
+ cacheWriteTokens: 0,
237
+ totalTokens: 0,
238
+ turnCount: 0,
239
+ totalCost: 0,
240
+ });
241
+ }
242
+ const agentAggregate = byDateAgent.get(agentMapKey);
243
+ agentAggregate.inputTokens += metrics.inputTokens;
244
+ agentAggregate.outputTokens += metrics.outputTokens;
245
+ agentAggregate.cacheReadTokens += metrics.cacheReadTokens;
246
+ agentAggregate.cacheWriteTokens += metrics.cacheWriteTokens;
247
+ agentAggregate.totalTokens += metrics.totalTokens;
248
+ agentAggregate.turnCount += 1;
249
+ agentAggregate.totalCost += metrics.totalCost;
200
250
  }
201
251
  const enriched = Array.from(byDateModel.values()).sort((a, b) => {
202
252
  if (a.date === b.date) return b.totalTokens - a.totalTokens;
@@ -227,6 +277,50 @@ const getDailySummary = ({
227
277
  pricingFound: row.pricingFound,
228
278
  });
229
279
  }
280
+ const byDateSourceRows = new Map();
281
+ for (const row of byDateSource.values()) {
282
+ if (!byDateSourceRows.has(row.date)) byDateSourceRows.set(row.date, []);
283
+ byDateSourceRows.get(row.date).push({
284
+ source: row.source,
285
+ inputTokens: row.inputTokens,
286
+ outputTokens: row.outputTokens,
287
+ cacheReadTokens: row.cacheReadTokens,
288
+ cacheWriteTokens: row.cacheWriteTokens,
289
+ totalTokens: row.totalTokens,
290
+ turnCount: row.turnCount,
291
+ totalCost: row.totalCost,
292
+ });
293
+ }
294
+ for (const rows of byDateSourceRows.values()) {
295
+ rows.sort((left, right) => {
296
+ if (right.totalTokens !== left.totalTokens) {
297
+ return right.totalTokens - left.totalTokens;
298
+ }
299
+ return String(left.source || "").localeCompare(String(right.source || ""));
300
+ });
301
+ }
302
+ const byDateAgentRows = new Map();
303
+ for (const row of byDateAgent.values()) {
304
+ if (!byDateAgentRows.has(row.date)) byDateAgentRows.set(row.date, []);
305
+ byDateAgentRows.get(row.date).push({
306
+ agent: row.agent,
307
+ inputTokens: row.inputTokens,
308
+ outputTokens: row.outputTokens,
309
+ cacheReadTokens: row.cacheReadTokens,
310
+ cacheWriteTokens: row.cacheWriteTokens,
311
+ totalTokens: row.totalTokens,
312
+ turnCount: row.turnCount,
313
+ totalCost: row.totalCost,
314
+ });
315
+ }
316
+ for (const rows of byDateAgentRows.values()) {
317
+ rows.sort((left, right) => {
318
+ if (right.totalTokens !== left.totalTokens) {
319
+ return right.totalTokens - left.totalTokens;
320
+ }
321
+ return String(left.agent || "").localeCompare(String(right.agent || ""));
322
+ });
323
+ }
230
324
  const daily = [];
231
325
  const totals = {
232
326
  inputTokens: 0,
@@ -259,7 +353,13 @@ const getDailySummary = ({
259
353
  turnCount: 0,
260
354
  },
261
355
  );
262
- daily.push({ date, ...aggregate, models: modelRows });
356
+ daily.push({
357
+ date,
358
+ ...aggregate,
359
+ models: modelRows,
360
+ sources: byDateSourceRows.get(date) || [],
361
+ agents: byDateAgentRows.get(date) || [],
362
+ });
263
363
  totals.inputTokens += aggregate.inputTokens;
264
364
  totals.outputTokens += aggregate.outputTokens;
265
365
  totals.cacheReadTokens += aggregate.cacheReadTokens;
@@ -124,6 +124,7 @@ const registerServerRoutes = ({
124
124
  getChannelStatus,
125
125
  openclawVersionService,
126
126
  alphaclawVersionService,
127
+ kAlphaclawGithubReleasesBaseUrl: constants.kAlphaclawGithubReleasesBaseUrl,
127
128
  clawCmd,
128
129
  restartGateway,
129
130
  OPENCLAW_DIR: constants.OPENCLAW_DIR,
@@ -1,6 +1,7 @@
1
1
  const { buildManagedPaths } = require("../internal-files-migration");
2
2
  const { readOpenclawConfig } = require("../openclaw-config");
3
3
  const { hasScopedBindingFields } = require("../utils/channels");
4
+ const https = require("https");
4
5
 
5
6
  const registerSystemRoutes = ({
6
7
  app,
@@ -17,6 +18,7 @@ const registerSystemRoutes = ({
17
18
  getChannelStatus,
18
19
  openclawVersionService,
19
20
  alphaclawVersionService,
21
+ kAlphaclawGithubReleasesBaseUrl,
20
22
  clawCmd,
21
23
  restartGateway,
22
24
  OPENCLAW_DIR,
@@ -289,6 +291,71 @@ const registerSystemRoutes = ({
289
291
  return getSystemCronStatus();
290
292
  };
291
293
  const isVisibleInEnvars = (def) => def?.visibleInEnvars !== false;
294
+ const kReleaseNotesCacheTtlMs = 5 * 60 * 1000;
295
+ let kReleaseNotesCache = {
296
+ key: "",
297
+ fetchedAt: 0,
298
+ payload: null,
299
+ };
300
+ const isValidReleaseTag = (value) =>
301
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(value || ""));
302
+ const fetchGitHubRelease = (tag = "") =>
303
+ new Promise((resolve, reject) => {
304
+ const normalizedTag = String(tag || "").trim();
305
+ const endpointPath = normalizedTag
306
+ ? `/tags/${encodeURIComponent(normalizedTag)}`
307
+ : "/latest";
308
+ const requestUrl = `${kAlphaclawGithubReleasesBaseUrl}${endpointPath}`;
309
+ const token = String(process.env.GITHUB_TOKEN || "").trim();
310
+ const headers = {
311
+ Accept: "application/vnd.github+json",
312
+ "User-Agent": "alphaclaw-release-notes",
313
+ };
314
+ if (token) headers.Authorization = `Bearer ${token}`;
315
+ const request = https.get(
316
+ requestUrl,
317
+ { headers, timeout: 7000 },
318
+ (response) => {
319
+ let raw = "";
320
+ response.setEncoding("utf8");
321
+ response.on("data", (chunk) => {
322
+ raw += chunk;
323
+ });
324
+ response.on("end", () => {
325
+ let parsed = null;
326
+ try {
327
+ parsed = raw ? JSON.parse(raw) : null;
328
+ } catch {
329
+ parsed = null;
330
+ }
331
+ const statusCode = Number(response.statusCode) || 500;
332
+ if (statusCode >= 400) {
333
+ const message =
334
+ parsed?.message ||
335
+ `GitHub release lookup failed with status ${statusCode}`;
336
+ return reject(
337
+ Object.assign(new Error(message), {
338
+ statusCode,
339
+ }),
340
+ );
341
+ }
342
+ resolve({
343
+ tag: String(parsed?.tag_name || normalizedTag || ""),
344
+ name: String(parsed?.name || "").trim(),
345
+ body: String(parsed?.body || ""),
346
+ htmlUrl: String(parsed?.html_url || "").trim(),
347
+ publishedAt: String(parsed?.published_at || "").trim(),
348
+ });
349
+ });
350
+ },
351
+ );
352
+ request.on("timeout", () => {
353
+ request.destroy(new Error("GitHub release request timed out"));
354
+ });
355
+ request.on("error", (error) => {
356
+ reject(error);
357
+ });
358
+ });
292
359
 
293
360
  app.get("/api/env", (req, res) => {
294
361
  const fileVars = readEnvFile();
@@ -466,6 +533,37 @@ const registerSystemRoutes = ({
466
533
  res.json(status);
467
534
  });
468
535
 
536
+ app.get("/api/alphaclaw/release-notes", async (req, res) => {
537
+ const requestedTag = String(req.query.tag || "").trim();
538
+ if (requestedTag && !isValidReleaseTag(requestedTag)) {
539
+ return res.status(400).json({ ok: false, error: "Invalid release tag" });
540
+ }
541
+ const cacheKey = requestedTag || "latest";
542
+ const now = Date.now();
543
+ if (
544
+ kReleaseNotesCache.payload &&
545
+ kReleaseNotesCache.key === cacheKey &&
546
+ now - kReleaseNotesCache.fetchedAt < kReleaseNotesCacheTtlMs
547
+ ) {
548
+ return res.json({ ok: true, ...kReleaseNotesCache.payload });
549
+ }
550
+ try {
551
+ const payload = await fetchGitHubRelease(requestedTag);
552
+ kReleaseNotesCache = {
553
+ key: cacheKey,
554
+ fetchedAt: Date.now(),
555
+ payload,
556
+ };
557
+ return res.json({ ok: true, ...payload });
558
+ } catch (err) {
559
+ const statusCode = Number(err?.statusCode) || 502;
560
+ return res.status(statusCode).json({
561
+ ok: false,
562
+ error: err?.message || "Could not fetch release notes",
563
+ });
564
+ }
565
+ });
566
+
469
567
  app.post("/api/alphaclaw/update", async (req, res) => {
470
568
  console.log("[alphaclaw] /api/alphaclaw/update requested");
471
569
  const result = await alphaclawVersionService.updateAlphaclaw();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.7.1",
3
+ "version": "0.7.2-beta.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },