@chrysb/alphaclaw 0.6.2-beta.5 → 0.7.0-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 (33) hide show
  1. package/lib/public/css/agents.css +37 -13
  2. package/lib/public/css/cron.css +124 -41
  3. package/lib/public/css/shell.css +61 -2
  4. package/lib/public/css/theme.css +2 -1
  5. package/lib/public/js/app.js +41 -33
  6. package/lib/public/js/components/agents-tab/agent-detail-panel.js +61 -49
  7. package/lib/public/js/components/agents-tab/agent-overview/index.js +9 -0
  8. package/lib/public/js/components/agents-tab/agent-overview/tools-card.js +54 -0
  9. package/lib/public/js/components/cron-tab/cron-calendar.js +297 -203
  10. package/lib/public/js/components/cron-tab/cron-helpers.js +48 -0
  11. package/lib/public/js/components/cron-tab/cron-insights-panel.js +294 -0
  12. package/lib/public/js/components/cron-tab/cron-job-detail.js +38 -363
  13. package/lib/public/js/components/cron-tab/cron-job-settings-card.js +233 -0
  14. package/lib/public/js/components/cron-tab/cron-overview.js +40 -19
  15. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +173 -0
  16. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +74 -62
  17. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +24 -24
  18. package/lib/public/js/components/cron-tab/index.js +170 -78
  19. package/lib/public/js/components/envars.js +187 -46
  20. package/lib/public/js/components/file-viewer/editor-surface.js +5 -1
  21. package/lib/public/js/components/file-viewer/use-editor-line-number-sync.js +36 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer.js +7 -23
  23. package/lib/public/js/components/file-viewer/utils.js +1 -5
  24. package/lib/public/js/components/models-tab/index.js +137 -133
  25. package/lib/public/js/components/models-tab/provider-auth-card.js +8 -1
  26. package/lib/public/js/components/models-tab/use-models.js +35 -8
  27. package/lib/public/js/components/onboarding/welcome-pairing-step.js +88 -59
  28. package/lib/public/js/components/pane-shell.js +27 -0
  29. package/lib/public/js/components/routes/envars-route.js +1 -3
  30. package/lib/public/js/components/routes/models-route.js +1 -3
  31. package/lib/public/js/lib/app-navigation.js +1 -1
  32. package/lib/server/cost-utils.js +2 -2
  33. package/package.json +1 -1
@@ -276,6 +276,54 @@ export const getCronJobHealthClassName = (health = "") => {
276
276
 
277
277
  export const formatTokenCount = (value) => formatInteger(Number(value || 0));
278
278
  export const formatCost = (value) => formatUsd(Number(value || 0));
279
+ export const getCronRunTotalTokens = (entry = {}) => {
280
+ const usage = entry?.usage || {};
281
+ const componentCandidates = [
282
+ usage?.input_tokens,
283
+ usage?.inputTokens,
284
+ usage?.output_tokens,
285
+ usage?.outputTokens,
286
+ usage?.cache_read_tokens,
287
+ usage?.cacheReadTokens,
288
+ usage?.cache_write_tokens,
289
+ usage?.cacheWriteTokens,
290
+ ];
291
+ const componentTotal = componentCandidates.reduce((sum, candidate) => {
292
+ const numericValue = Number(candidate);
293
+ if (!Number.isFinite(numericValue) || numericValue < 0) return sum;
294
+ return sum + numericValue;
295
+ }, 0);
296
+ if (componentTotal > 0) return componentTotal;
297
+ const totalCandidates = [
298
+ usage?.total_tokens,
299
+ usage?.totalTokens,
300
+ entry?.total_tokens,
301
+ entry?.totalTokens,
302
+ ];
303
+ for (const candidate of totalCandidates) {
304
+ const numericValue = Number(candidate);
305
+ if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
306
+ }
307
+ return 0;
308
+ };
309
+ export const getCronRunEstimatedCost = (entry = {}) => {
310
+ const usage = entry?.usage || {};
311
+ const candidates = [
312
+ entry?.estimatedCost,
313
+ entry?.estimated_cost,
314
+ usage?.estimatedCost,
315
+ usage?.estimated_cost,
316
+ usage?.totalCost,
317
+ usage?.total_cost,
318
+ usage?.costUsd,
319
+ usage?.cost,
320
+ ];
321
+ for (const candidate of candidates) {
322
+ const numericValue = Number(candidate);
323
+ if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
324
+ }
325
+ return null;
326
+ };
279
327
  const hasHeartbeatOnlySummary = (job = {}) => {
280
328
  const state = job?.state || {};
281
329
  try {
@@ -0,0 +1,294 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { SegmentedControl } from "../segmented-control.js";
5
+ import { Badge } from "../badge.js";
6
+ import {
7
+ formatCost,
8
+ formatTokenCount,
9
+ getCronRunEstimatedCost,
10
+ getCronRunTotalTokens,
11
+ } from "./cron-helpers.js";
12
+
13
+ const html = htm.bind(h);
14
+
15
+ const kRange24h = "24h";
16
+ const kRange7d = "7d";
17
+ const kRange30d = "30d";
18
+ const kRangeOptions = [
19
+ { label: "24h", value: kRange24h },
20
+ { label: "7d", value: kRange7d },
21
+ { label: "30d", value: kRange30d },
22
+ ];
23
+ const kRangeWindowMsByValue = {
24
+ [kRange24h]: 24 * 60 * 60 * 1000,
25
+ [kRange7d]: 7 * 24 * 60 * 60 * 1000,
26
+ [kRange30d]: 30 * 24 * 60 * 60 * 1000,
27
+ };
28
+ const kTopListLimit = 3;
29
+ const kBadgeToneByInsight = {
30
+ "Token hungry": "warning",
31
+ "Potentially wasteful": "danger",
32
+ "Most expensive": "accent",
33
+ };
34
+ const kWastefulMinRuns = 10;
35
+ const kTokenHungryMinAvgTokensPerRun = 100000;
36
+ const kTokenHungryMinRuns = 3;
37
+ const formatRunCountLabel = (count = 0) => {
38
+ const safeCount = Number(count || 0);
39
+ const countLabel = formatTokenCount(safeCount);
40
+ return `${countLabel} ${safeCount === 1 ? "run" : "runs"}`;
41
+ };
42
+
43
+ const readDeliveryMode = (job = null) =>
44
+ String(job?.delivery?.mode || job?.deliveryMode || "none")
45
+ .trim()
46
+ .toLowerCase();
47
+
48
+ const sortDescBy = (items = [], selectors = []) =>
49
+ [...items].sort((left, right) => {
50
+ for (const selector of selectors) {
51
+ const leftValue = Number(selector(left) || 0);
52
+ const rightValue = Number(selector(right) || 0);
53
+ if (leftValue === rightValue) continue;
54
+ return rightValue - leftValue;
55
+ }
56
+ return String(left?.jobName || "").localeCompare(
57
+ String(right?.jobName || ""),
58
+ );
59
+ });
60
+
61
+ const buildInsightMetrics = ({
62
+ jobs = [],
63
+ bulkRunsByJobId = {},
64
+ rangeValue = kRange7d,
65
+ }) => {
66
+ const nowMs = Date.now();
67
+ const windowMs = Number(
68
+ kRangeWindowMsByValue[rangeValue] || kRangeWindowMsByValue[kRange7d],
69
+ );
70
+ const cutoffMs = nowMs - windowMs;
71
+ const metricsByJobId = jobs.reduce((accumulator, job) => {
72
+ const jobId = String(job?.id || "");
73
+ if (!jobId) return accumulator;
74
+ accumulator[jobId] = {
75
+ jobId,
76
+ jobName: String(job?.name || jobId),
77
+ runCount: 0,
78
+ totalTokens: 0,
79
+ totalCost: 0,
80
+ hasCostData: false,
81
+ hasDelivery: readDeliveryMode(job) !== "none",
82
+ };
83
+ return accumulator;
84
+ }, {});
85
+
86
+ Object.entries(bulkRunsByJobId || {}).forEach(([jobIdValue, runResult]) => {
87
+ const jobId = String(jobIdValue || "");
88
+ if (!jobId) return;
89
+ if (!metricsByJobId[jobId]) {
90
+ metricsByJobId[jobId] = {
91
+ jobId,
92
+ jobName: jobId,
93
+ runCount: 0,
94
+ totalTokens: 0,
95
+ totalCost: 0,
96
+ hasCostData: false,
97
+ hasDelivery: false,
98
+ };
99
+ }
100
+ const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
101
+ entries.forEach((entry) => {
102
+ const timestampMs = Number(entry?.ts || 0);
103
+ if (
104
+ !Number.isFinite(timestampMs) ||
105
+ timestampMs < cutoffMs ||
106
+ timestampMs > nowMs
107
+ ) {
108
+ return;
109
+ }
110
+ metricsByJobId[jobId].runCount += 1;
111
+ metricsByJobId[jobId].totalTokens += Number(getCronRunTotalTokens(entry) || 0);
112
+ const estimatedCost = getCronRunEstimatedCost(entry);
113
+ if (estimatedCost != null) {
114
+ metricsByJobId[jobId].hasCostData = true;
115
+ metricsByJobId[jobId].totalCost += Number(estimatedCost || 0);
116
+ }
117
+ });
118
+ });
119
+
120
+ return Object.values(metricsByJobId).map((entry) => ({
121
+ ...entry,
122
+ avgTokensPerRun:
123
+ entry.runCount > 0 ? Math.round(entry.totalTokens / entry.runCount) : 0,
124
+ avgCostPerRun: entry.runCount > 0 ? entry.totalCost / entry.runCount : 0,
125
+ }));
126
+ };
127
+
128
+ const renderInsightRow = ({
129
+ title = "",
130
+ rows = [],
131
+ onSelectJob = () => {},
132
+ }) => {
133
+ const badgeTone = kBadgeToneByInsight[title] || "neutral";
134
+ const topRow = rows[0];
135
+ const overflowRows = rows.slice(1);
136
+ return html`
137
+ <div class="rounded-lg border border-border bg-black/20 px-3 py-2 space-y-1.5">
138
+ <button
139
+ type="button"
140
+ class="w-full text-left hover:brightness-110 transition"
141
+ onClick=${() => onSelectJob(topRow.jobId)}
142
+ >
143
+ <div class="flex items-start justify-between gap-3">
144
+ <div class="min-w-0">
145
+ <div class="text-sm text-gray-100 truncate">${topRow.jobName}</div>
146
+ <div class="text-xs text-gray-400 truncate mt-1">
147
+ ${`${topRow.primaryLabel} · ${topRow.secondaryLabel}`}
148
+ </div>
149
+ </div>
150
+ <div class="shrink-0 inline-flex items-center gap-1.5">
151
+ <${Badge} tone=${badgeTone}>${title}</${Badge}>
152
+ </div>
153
+ </div>
154
+ </button>
155
+ ${
156
+ overflowRows.length > 0
157
+ ? html`
158
+ <details class="group">
159
+ <summary
160
+ class="list-none cursor-pointer text-[11px] text-gray-500 hover:text-gray-300"
161
+ >
162
+ Show more
163
+ </summary>
164
+ <div class="mt-1.5 divide-y divide-border">
165
+ ${overflowRows.map(
166
+ (row, index) => html`
167
+ <button
168
+ key=${`${title}:${row.jobId}`}
169
+ type="button"
170
+ class="w-full text-left py-2 hover:brightness-110 transition"
171
+ onClick=${() => onSelectJob(row.jobId)}
172
+ >
173
+ <div class="flex items-start justify-between gap-3">
174
+ <div class="min-w-0">
175
+ <div class="text-sm text-gray-200 truncate">
176
+ ${row.jobName}
177
+ </div>
178
+ <div
179
+ class="text-[11px] text-gray-500 truncate mt-1"
180
+ >
181
+ ${`${row.primaryLabel} · ${row.secondaryLabel}`}
182
+ </div>
183
+ </div>
184
+ <div
185
+ class="text-[11px] uppercase tracking-wide text-gray-500"
186
+ >
187
+ #${index + 2}
188
+ </div>
189
+ </div>
190
+ </button>
191
+ `,
192
+ )}
193
+ </div>
194
+ </details>
195
+ `
196
+ : null
197
+ }
198
+ </div>
199
+ `;
200
+ };
201
+
202
+ export const CronInsightsPanel = ({
203
+ jobs = [],
204
+ bulkRunsByJobId = {},
205
+ onSelectJob = () => {},
206
+ }) => {
207
+ const [rangeValue, setRangeValue] = useState(kRange7d);
208
+ const metrics = useMemo(
209
+ () => buildInsightMetrics({ jobs, bulkRunsByJobId, rangeValue }),
210
+ [bulkRunsByJobId, jobs, rangeValue],
211
+ );
212
+
213
+ const tokenHungryRows = useMemo(
214
+ () =>
215
+ sortDescBy(
216
+ metrics.filter(
217
+ (entry) =>
218
+ entry.runCount >= kTokenHungryMinRuns &&
219
+ entry.avgTokensPerRun >= kTokenHungryMinAvgTokensPerRun,
220
+ ),
221
+ [(entry) => entry.avgTokensPerRun, (entry) => entry.totalTokens],
222
+ )
223
+ .slice(0, kTopListLimit)
224
+ .map((entry) => ({
225
+ ...entry,
226
+ primaryLabel: `${formatTokenCount(entry.avgTokensPerRun)} avg tokens/run`,
227
+ secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens · ${formatRunCountLabel(entry.runCount)}`,
228
+ })),
229
+ [metrics],
230
+ );
231
+
232
+ const wastefulRows = useMemo(
233
+ () =>
234
+ sortDescBy(
235
+ metrics.filter(
236
+ (entry) =>
237
+ entry.runCount >= kWastefulMinRuns &&
238
+ entry.hasDelivery === false &&
239
+ (entry.totalTokens > 0 || entry.totalCost > 0),
240
+ ),
241
+ [(entry) => entry.totalTokens, (entry) => entry.runCount],
242
+ )
243
+ .slice(0, kTopListLimit)
244
+ .map((entry) => ({
245
+ ...entry,
246
+ primaryLabel: `${formatRunCountLabel(entry.runCount)} with no delivery`,
247
+ secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens${entry.hasCostData ? ` · ${formatCost(entry.totalCost)}` : ""}`,
248
+ })),
249
+ [metrics],
250
+ );
251
+
252
+ const expensiveRows = useMemo(
253
+ () =>
254
+ sortDescBy(
255
+ metrics.filter((entry) => entry.runCount > 0 && entry.totalCost > 0),
256
+ [(entry) => entry.totalCost, (entry) => entry.avgCostPerRun],
257
+ )
258
+ .slice(0, kTopListLimit)
259
+ .map((entry) => ({
260
+ ...entry,
261
+ primaryLabel: `${formatCost(entry.totalCost)} total estimated cost`,
262
+ secondaryLabel: `${formatCost(entry.avgCostPerRun)} avg/run · ${formatRunCountLabel(entry.runCount)}`,
263
+ })),
264
+ [metrics],
265
+ );
266
+ const insightRows = [
267
+ { title: "Token hungry", rows: tokenHungryRows },
268
+ { title: "Potentially wasteful", rows: wastefulRows },
269
+ { title: "Most expensive", rows: expensiveRows },
270
+ ].filter((group) => Array.isArray(group.rows) && group.rows.length > 0);
271
+ if (insightRows.length === 0) return null;
272
+
273
+ return html`
274
+ <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
275
+ <div class="flex items-center justify-between gap-2">
276
+ <h3 class="card-label cron-calendar-title">Insights</h3>
277
+ <${SegmentedControl}
278
+ options=${kRangeOptions}
279
+ value=${rangeValue}
280
+ onChange=${setRangeValue}
281
+ />
282
+ </div>
283
+
284
+ <div class="grid grid-cols-1 gap-2">
285
+ ${insightRows.map((group) =>
286
+ renderInsightRow({
287
+ title: group.title,
288
+ rows: group.rows,
289
+ onSelectJob,
290
+ }))}
291
+ </div>
292
+ </section>
293
+ `;
294
+ };