@chrysb/alphaclaw 0.6.1 → 0.6.2-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 (50) hide show
  1. package/lib/public/css/agents.css +1 -1
  2. package/lib/public/css/cron.css +535 -0
  3. package/lib/public/css/theme.css +72 -0
  4. package/lib/public/js/app.js +45 -10
  5. package/lib/public/js/components/action-button.js +26 -20
  6. package/lib/public/js/components/agents-tab/agent-detail-panel.js +98 -17
  7. package/lib/public/js/components/agents-tab/agent-tools/index.js +105 -0
  8. package/lib/public/js/components/agents-tab/agent-tools/tool-catalog.js +289 -0
  9. package/lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js +128 -0
  10. package/lib/public/js/components/agents-tab/index.js +4 -0
  11. package/lib/public/js/components/cron-tab/cron-calendar-helpers.js +385 -0
  12. package/lib/public/js/components/cron-tab/cron-calendar.js +441 -0
  13. package/lib/public/js/components/cron-tab/cron-helpers.js +326 -0
  14. package/lib/public/js/components/cron-tab/cron-job-detail.js +425 -0
  15. package/lib/public/js/components/cron-tab/cron-job-list.js +305 -0
  16. package/lib/public/js/components/cron-tab/cron-job-usage.js +70 -0
  17. package/lib/public/js/components/cron-tab/cron-overview.js +599 -0
  18. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +277 -0
  19. package/lib/public/js/components/cron-tab/index.js +100 -0
  20. package/lib/public/js/components/cron-tab/use-cron-tab.js +366 -0
  21. package/lib/public/js/components/doctor/summary-cards.js +5 -11
  22. package/lib/public/js/components/icons.js +13 -0
  23. package/lib/public/js/components/pill-tabs.js +33 -0
  24. package/lib/public/js/components/pop-actions.js +58 -0
  25. package/lib/public/js/components/routes/agents-route.js +4 -0
  26. package/lib/public/js/components/routes/cron-route.js +9 -0
  27. package/lib/public/js/components/routes/index.js +1 -0
  28. package/lib/public/js/components/segmented-control.js +15 -9
  29. package/lib/public/js/components/summary-stat-card.js +17 -0
  30. package/lib/public/js/components/tooltip.js +50 -4
  31. package/lib/public/js/components/watchdog-tab.js +46 -1
  32. package/lib/public/js/lib/api.js +94 -0
  33. package/lib/public/js/lib/app-navigation.js +2 -0
  34. package/lib/public/js/lib/storage-keys.js +1 -0
  35. package/lib/public/setup.html +1 -0
  36. package/lib/server/agents/agents.js +15 -0
  37. package/lib/server/constants.js +1 -0
  38. package/lib/server/cost-utils.js +312 -0
  39. package/lib/server/cron-service.js +461 -0
  40. package/lib/server/db/usage/index.js +100 -1
  41. package/lib/server/db/usage/pricing.js +1 -83
  42. package/lib/server/db/usage/sessions.js +4 -1
  43. package/lib/server/db/usage/shared.js +2 -1
  44. package/lib/server/db/usage/summary.js +5 -1
  45. package/lib/server/onboarding/index.js +39 -5
  46. package/lib/server/onboarding/openclaw.js +25 -19
  47. package/lib/server/onboarding/validation.js +28 -0
  48. package/lib/server/routes/cron.js +148 -0
  49. package/lib/server.js +13 -0
  50. package/package.json +1 -1
@@ -0,0 +1,599 @@
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 {
5
+ buildCronOptimizationWarnings,
6
+ formatCost,
7
+ formatRelativeMs,
8
+ formatTokenCount,
9
+ getNextScheduledRunAcrossJobs,
10
+ } from "./cron-helpers.js";
11
+ import { CronCalendar } from "./cron-calendar.js";
12
+ import { CronRunsTrendCard } from "./cron-runs-trend-card.js";
13
+ import { SegmentedControl } from "../segmented-control.js";
14
+ import { SummaryStatCard } from "../summary-stat-card.js";
15
+ import { ErrorWarningLineIcon } from "../icons.js";
16
+ import {
17
+ formatDurationCompactMs,
18
+ formatLocaleDateTimeWithTodayTime,
19
+ } from "../../lib/format.js";
20
+
21
+ const html = htm.bind(h);
22
+ const kRecentRunFetchLimit = 100;
23
+ const kRecentRunRowsLimit = 20;
24
+ const kRecentRunCollapseThreshold = 5;
25
+ const kTrendRange7d = "7d";
26
+ const kTrendRange30d = "30d";
27
+ const kTrendQueryStartKey = "trendStart";
28
+ const kTrendQueryEndKey = "trendEnd";
29
+ const kTrendQueryRangeKey = "trendRange";
30
+ const kTrendQueryLabelKey = "trendLabel";
31
+
32
+ const kRunStatusFilterOptions = [
33
+ { label: "all", value: "all" },
34
+ { label: "ok", value: "ok" },
35
+ { label: "error", value: "error" },
36
+ { label: "skipped", value: "skipped" },
37
+ ];
38
+
39
+ const warningClassName = (tone) => {
40
+ if (tone === "error") return "border-red-900 bg-red-950/30 text-red-200";
41
+ if (tone === "warning")
42
+ return "border-yellow-900 bg-yellow-950/30 text-yellow-100";
43
+ return "border-border bg-black/20 text-gray-200";
44
+ };
45
+
46
+ const formatWarningsAttentionText = (warnings = []) => {
47
+ const errorCount = warnings.filter(
48
+ (warning) => warning?.tone === "error",
49
+ ).length;
50
+ const warningCount = warnings.filter(
51
+ (warning) => warning?.tone === "warning",
52
+ ).length;
53
+ const totalCount = errorCount + warningCount;
54
+ if (totalCount <= 0) return "No warnings currently need your attention";
55
+ const parts = [];
56
+ if (errorCount > 0)
57
+ parts.push(`${errorCount} error${errorCount === 1 ? "" : "s"}`);
58
+ if (warningCount > 0)
59
+ parts.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`);
60
+ return `${parts.join(" and ")} may need your attention`;
61
+ };
62
+
63
+ const runStatusClassName = (status = "") => {
64
+ const normalized = String(status || "")
65
+ .trim()
66
+ .toLowerCase();
67
+ if (normalized === "ok") return "text-green-300";
68
+ if (normalized === "error") return "text-red-300";
69
+ if (normalized === "skipped") return "text-yellow-300";
70
+ return "text-gray-400";
71
+ };
72
+
73
+ const formatRecentRunTimestamp = (timestampMs) =>
74
+ formatLocaleDateTimeWithTodayTime(timestampMs, {
75
+ fallback: "—",
76
+ valueIsEpochMs: true,
77
+ }).replace(
78
+ /\s([AP])M\b/g,
79
+ (_, marker) => `${String(marker || "").toLowerCase()}m`,
80
+ );
81
+
82
+ const getRunEstimatedCost = (runEntry = {}) => {
83
+ const usage = runEntry?.usage || {};
84
+ const candidates = [
85
+ usage?.estimatedCost,
86
+ usage?.estimated_cost,
87
+ usage?.totalCost,
88
+ usage?.total_cost,
89
+ usage?.costUsd,
90
+ usage?.cost,
91
+ runEntry?.estimatedCost,
92
+ runEntry?.estimated_cost,
93
+ runEntry?.totalCost,
94
+ runEntry?.total_cost,
95
+ runEntry?.costUsd,
96
+ runEntry?.cost,
97
+ ];
98
+ for (const candidate of candidates) {
99
+ const numericValue = Number(candidate);
100
+ if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
101
+ }
102
+ return null;
103
+ };
104
+
105
+ const flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [] } = {}) => {
106
+ const jobNameById = jobs.reduce((accumulator, job) => {
107
+ const jobId = String(job?.id || "");
108
+ if (!jobId) return accumulator;
109
+ accumulator[jobId] = String(job?.name || jobId);
110
+ return accumulator;
111
+ }, {});
112
+ return Object.entries(bulkRunsByJobId || {})
113
+ .flatMap(([jobId, runResult]) => {
114
+ const entries = Array.isArray(runResult?.entries)
115
+ ? runResult.entries
116
+ : [];
117
+ return entries.map((entry) => ({
118
+ ...entry,
119
+ jobId: String(jobId || ""),
120
+ jobName: jobNameById[jobId] || String(jobId || ""),
121
+ }));
122
+ })
123
+ .filter((entry) => Number(entry?.ts || 0) > 0)
124
+ .sort((left, right) => Number(right?.ts || 0) - Number(left?.ts || 0))
125
+ .slice(0, kRecentRunFetchLimit);
126
+ };
127
+
128
+ const buildCollapsedRunRows = (recentRuns = []) => {
129
+ const rows = [];
130
+ let index = 0;
131
+ while (index < recentRuns.length && rows.length < kRecentRunRowsLimit) {
132
+ const current = recentRuns[index];
133
+ let streakEnd = index + 1;
134
+ while (
135
+ streakEnd < recentRuns.length &&
136
+ String(recentRuns[streakEnd]?.jobId || "") ===
137
+ String(current?.jobId || "")
138
+ ) {
139
+ streakEnd += 1;
140
+ }
141
+ const streak = recentRuns.slice(index, streakEnd);
142
+ if (streak.length >= kRecentRunCollapseThreshold) {
143
+ const statusCounts = streak.reduce((accumulator, runEntry) => {
144
+ const status = String(runEntry?.status || "unknown");
145
+ accumulator[status] = Number(accumulator[status] || 0) + 1;
146
+ return accumulator;
147
+ }, {});
148
+ rows.push({
149
+ type: "collapsed-group",
150
+ jobId: String(current?.jobId || ""),
151
+ jobName: String(current?.jobName || current?.jobId || ""),
152
+ count: streak.length,
153
+ newestTs: Number(streak[0]?.ts || 0),
154
+ oldestTs: Number(streak[streak.length - 1]?.ts || 0),
155
+ statusCounts,
156
+ });
157
+ index = streakEnd;
158
+ continue;
159
+ }
160
+ for (const runEntry of streak) {
161
+ if (rows.length >= kRecentRunRowsLimit) break;
162
+ rows.push({
163
+ type: "entry",
164
+ entry: runEntry,
165
+ });
166
+ }
167
+ index = streakEnd;
168
+ }
169
+ return rows;
170
+ };
171
+
172
+ const getHashRouteParts = () => {
173
+ const rawHash = String(window.location.hash || "").replace(/^#/, "");
174
+ const hashPath = rawHash || "/cron";
175
+ const [pathPart, queryPart = ""] = hashPath.split("?");
176
+ return {
177
+ pathPart: pathPart || "/cron",
178
+ params: new URLSearchParams(queryPart),
179
+ };
180
+ };
181
+
182
+ const readTrendFilterFromHash = () => {
183
+ const { params } = getHashRouteParts();
184
+ const startMs = Number(params.get(kTrendQueryStartKey) || 0);
185
+ const endMs = Number(params.get(kTrendQueryEndKey) || 0);
186
+ const range = String(params.get(kTrendQueryRangeKey) || kTrendRange7d);
187
+ const label = String(params.get(kTrendQueryLabelKey) || "");
188
+ const hasValidRange = range === kTrendRange7d || range === kTrendRange30d;
189
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
190
+ return null;
191
+ }
192
+ return {
193
+ startMs,
194
+ endMs,
195
+ range: hasValidRange ? range : kTrendRange7d,
196
+ label: label || "selected period",
197
+ };
198
+ };
199
+
200
+ const writeTrendFilterToHash = (filterValue = null) => {
201
+ const { pathPart, params } = getHashRouteParts();
202
+ if (!filterValue) {
203
+ params.delete(kTrendQueryStartKey);
204
+ params.delete(kTrendQueryEndKey);
205
+ params.delete(kTrendQueryRangeKey);
206
+ params.delete(kTrendQueryLabelKey);
207
+ } else {
208
+ params.set(kTrendQueryStartKey, String(Number(filterValue.startMs || 0)));
209
+ params.set(kTrendQueryEndKey, String(Number(filterValue.endMs || 0)));
210
+ params.set(
211
+ kTrendQueryRangeKey,
212
+ filterValue.range === kTrendRange30d ? kTrendRange30d : kTrendRange7d,
213
+ );
214
+ params.set(kTrendQueryLabelKey, String(filterValue.label || ""));
215
+ }
216
+ const nextQuery = params.toString();
217
+ const nextHash = nextQuery ? `#${pathPart}?${nextQuery}` : `#${pathPart}`;
218
+ const nextUrl =
219
+ `${window.location.pathname}${window.location.search}${nextHash}`;
220
+ window.history.replaceState(window.history.state, "", nextUrl);
221
+ };
222
+
223
+ export const CronOverview = ({
224
+ jobs = [],
225
+ bulkUsageByJobId = {},
226
+ bulkRunsByJobId = {},
227
+ onSelectJob = () => {},
228
+ }) => {
229
+ const [recentRunStatusFilter, setRecentRunStatusFilter] = useState("all");
230
+ const [selectedTrendBucketFilter, setSelectedTrendBucketFilter] = useState(
231
+ () => readTrendFilterFromHash(),
232
+ );
233
+ const enabledCount = jobs.filter((job) => job.enabled !== false).length;
234
+ const disabledCount = jobs.length - enabledCount;
235
+ const nextRunMs = getNextScheduledRunAcrossJobs(jobs);
236
+ const warnings = buildCronOptimizationWarnings(jobs);
237
+ const recentRuns = useMemo(
238
+ () => flattenRecentRuns({ bulkRunsByJobId, jobs }),
239
+ [bulkRunsByJobId, jobs],
240
+ );
241
+ const timeFilteredRecentRuns = useMemo(() => {
242
+ if (!selectedTrendBucketFilter) return recentRuns;
243
+ const startMs = Number(selectedTrendBucketFilter?.startMs || 0);
244
+ const endMs = Number(selectedTrendBucketFilter?.endMs || 0);
245
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
246
+ return recentRuns;
247
+ }
248
+ return recentRuns.filter((entry) => {
249
+ const timestampMs = Number(entry?.ts || 0);
250
+ return Number.isFinite(timestampMs) && timestampMs >= startMs && timestampMs < endMs;
251
+ });
252
+ }, [recentRuns, selectedTrendBucketFilter]);
253
+ const filteredRecentRuns = useMemo(
254
+ () =>
255
+ timeFilteredRecentRuns.filter((entry) =>
256
+ recentRunStatusFilter === "all"
257
+ ? true
258
+ : String(entry?.status || "")
259
+ .trim()
260
+ .toLowerCase() === recentRunStatusFilter,
261
+ ),
262
+ [recentRunStatusFilter, timeFilteredRecentRuns],
263
+ );
264
+ const recentRunRows = useMemo(
265
+ () => buildCollapsedRunRows(filteredRecentRuns),
266
+ [filteredRecentRuns],
267
+ );
268
+ const initialTrendRange = selectedTrendBucketFilter?.range === kTrendRange30d
269
+ ? kTrendRange30d
270
+ : kTrendRange7d;
271
+ useEffect(() => {
272
+ writeTrendFilterToHash(selectedTrendBucketFilter);
273
+ }, [selectedTrendBucketFilter]);
274
+
275
+ return html`
276
+ <div class="cron-detail-scroll">
277
+ <div class="cron-detail-content">
278
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-3">
279
+ <${SummaryStatCard}
280
+ title="Total jobs"
281
+ value=${jobs.length}
282
+ monospace=${true}
283
+ />
284
+ <${SummaryStatCard}
285
+ title="Enabled"
286
+ value=${enabledCount}
287
+ monospace=${true}
288
+ />
289
+ <${SummaryStatCard}
290
+ title="Disabled"
291
+ value=${disabledCount}
292
+ monospace=${true}
293
+ />
294
+ <${SummaryStatCard}
295
+ title="Next scheduled run"
296
+ value=${nextRunMs ? formatRelativeMs(nextRunMs) : "—"}
297
+ valueClassName="text-sm font-medium text-gray-200 leading-snug"
298
+ />
299
+ </div>
300
+
301
+ <section class="bg-surface border border-border rounded-xl px-4 py-3">
302
+ <details class="group">
303
+ <summary class="list-none cursor-pointer">
304
+ <div class="flex items-center justify-between gap-2">
305
+ <div class="inline-flex items-center gap-2 min-w-0">
306
+ <${ErrorWarningLineIcon}
307
+ className="w-4 h-4 text-yellow-300 shrink-0"
308
+ />
309
+ <div class="text-xs text-yellow-100 truncate">
310
+ ${formatWarningsAttentionText(warnings)}
311
+ </div>
312
+ </div>
313
+ <span
314
+ class="text-gray-400 text-xs transition-transform group-open:rotate-90"
315
+ >▸</span
316
+ >
317
+ </div>
318
+ </summary>
319
+ <div class="mt-3">
320
+ ${warnings.length === 0
321
+ ? html`<div class="text-xs text-gray-500">
322
+ No warnings right now.
323
+ </div>`
324
+ : html`
325
+ <div class="space-y-2">
326
+ ${warnings.map(
327
+ (warning, index) => html`
328
+ <div
329
+ key=${`warning:${index}`}
330
+ class=${`rounded-xl border p-3 text-xs ${warningClassName(warning.tone)} ${warning?.jobId ? "cursor-pointer hover:brightness-110" : ""}`}
331
+ role=${warning?.jobId ? "button" : null}
332
+ tabindex=${warning?.jobId ? "0" : null}
333
+ onclick=${() => {
334
+ if (!warning?.jobId) return;
335
+ onSelectJob(warning.jobId);
336
+ }}
337
+ onKeyDown=${(event) => {
338
+ if (!warning?.jobId) return;
339
+ if (event.key !== "Enter" && event.key !== " ")
340
+ return;
341
+ event.preventDefault();
342
+ onSelectJob(warning.jobId);
343
+ }}
344
+ >
345
+ <div class="font-medium">${warning.title}</div>
346
+ <div class="mt-1 opacity-90">${warning.body}</div>
347
+ </div>
348
+ `,
349
+ )}
350
+ </div>
351
+ `}
352
+ </div>
353
+ </details>
354
+ </section>
355
+
356
+ <${CronCalendar}
357
+ jobs=${jobs}
358
+ usageByJobId=${bulkUsageByJobId}
359
+ runsByJobId=${bulkRunsByJobId}
360
+ onSelectJob=${onSelectJob}
361
+ />
362
+
363
+ <${CronRunsTrendCard}
364
+ bulkRunsByJobId=${bulkRunsByJobId}
365
+ initialRange=${initialTrendRange}
366
+ selectedBucketFilter=${selectedTrendBucketFilter}
367
+ onBucketFilterChange=${setSelectedTrendBucketFilter}
368
+ />
369
+
370
+ <section
371
+ class="bg-surface border border-border rounded-xl p-4 space-y-3"
372
+ >
373
+ <div class="flex items-start justify-between gap-3">
374
+ <div class="inline-flex items-center gap-3">
375
+ <h3 class="card-label card-label-bright">Run history</h3>
376
+ <div class="text-xs text-gray-500">
377
+ ${formatTokenCount(filteredRecentRuns.length)} entries
378
+ </div>
379
+ </div>
380
+ <div class="shrink-0">
381
+ <${SegmentedControl}
382
+ options=${kRunStatusFilterOptions}
383
+ value=${recentRunStatusFilter}
384
+ onChange=${setRecentRunStatusFilter}
385
+ />
386
+ </div>
387
+ </div>
388
+ ${selectedTrendBucketFilter
389
+ ? html`
390
+ <div class="flex items-center">
391
+ <span class="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-2 py-1 rounded-full border border-border text-gray-300 bg-black/20">
392
+ Filtered to ${selectedTrendBucketFilter.label}
393
+ <button
394
+ type="button"
395
+ class="text-gray-500 hover:text-gray-200 leading-none"
396
+ onclick=${() => setSelectedTrendBucketFilter(null)}
397
+ aria-label="Clear trend filter"
398
+ >
399
+ ×
400
+ </button>
401
+ </span>
402
+ </div>
403
+ `
404
+ : null}
405
+ ${recentRunRows.length === 0
406
+ ? html`<div class="text-sm text-gray-500">No runs found.</div>`
407
+ : html`
408
+ <div class="ac-history-list">
409
+ ${recentRunRows.map((row, rowIndex) => {
410
+ if (row.type === "collapsed-group") {
411
+ const statusSummary = Object.entries(
412
+ row.statusCounts || {},
413
+ )
414
+ .map(([status, count]) => `${status}: ${count}`)
415
+ .join(" • ");
416
+ const timeRangeLabel = `[${formatRecentRunTimestamp(row.oldestTs)} - ${formatRecentRunTimestamp(row.newestTs)}]`;
417
+ return html`
418
+ <details
419
+ key=${`collapsed:${rowIndex}:${row.jobId}`}
420
+ class="ac-history-item"
421
+ >
422
+ <summary class="ac-history-summary">
423
+ <div class="ac-history-summary-row">
424
+ <span
425
+ class="inline-flex items-center gap-2 min-w-0"
426
+ >
427
+ <span
428
+ class="ac-history-toggle shrink-0"
429
+ aria-hidden="true"
430
+ >▸</span
431
+ >
432
+ <span class="truncate text-xs text-gray-300">
433
+ ${row.jobName} -
434
+ ${formatTokenCount(row.count)} runs -
435
+ ${timeRangeLabel}
436
+ </span>
437
+ </span>
438
+ </div>
439
+ </summary>
440
+ <div class="ac-history-body space-y-2 text-xs">
441
+ <div class="text-gray-500">
442
+ ${formatTokenCount(row.count)} consecutive runs
443
+ collapsed (${timeRangeLabel})
444
+ </div>
445
+ <div class="text-gray-500">
446
+ Statuses: ${statusSummary}
447
+ </div>
448
+ <div>
449
+ <button
450
+ type="button"
451
+ class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
452
+ onclick=${() => onSelectJob(row.jobId)}
453
+ >
454
+ Open ${row.jobName}
455
+ </button>
456
+ </div>
457
+ </div>
458
+ </details>
459
+ `;
460
+ }
461
+ const runEntry = row.entry || {};
462
+ const runStatus = String(runEntry?.status || "unknown");
463
+ const runUsage = runEntry?.usage || {};
464
+ const runInputTokens = Number(
465
+ runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
466
+ );
467
+ const runOutputTokens = Number(
468
+ runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,
469
+ );
470
+ const runTokens = Number(
471
+ runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0,
472
+ );
473
+ const runEstimatedCost = getRunEstimatedCost(runEntry);
474
+ const runTitle = String(runEntry?.jobName || "").trim();
475
+ const hasRunTitle = runTitle.length > 0;
476
+ return html`
477
+ <details
478
+ key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
479
+ class="ac-history-item"
480
+ >
481
+ <summary class="ac-history-summary">
482
+ <div class="ac-history-summary-row">
483
+ <span
484
+ class="inline-flex items-center gap-2 min-w-0"
485
+ >
486
+ <span
487
+ class="ac-history-toggle shrink-0"
488
+ aria-hidden="true"
489
+ >▸</span
490
+ >
491
+ ${hasRunTitle
492
+ ? html`
493
+ <span
494
+ class="inline-flex items-center gap-2 min-w-0"
495
+ >
496
+ <span class="truncate text-xs text-gray-300">
497
+ ${runTitle}
498
+ </span>
499
+ <span class="text-xs text-gray-500 shrink-0">
500
+ ${formatRecentRunTimestamp(runEntry.ts)}
501
+ </span>
502
+ </span>
503
+ `
504
+ : html`
505
+ <span class="truncate text-xs text-gray-300">
506
+ ${runEntry.jobId} -
507
+ ${formatRecentRunTimestamp(runEntry.ts)}
508
+ </span>
509
+ `}
510
+ </span>
511
+ <span
512
+ class="inline-flex items-center gap-3 shrink-0 text-xs"
513
+ >
514
+ <span class=${runStatusClassName(runStatus)}
515
+ >${runStatus}</span
516
+ >
517
+ <span class="text-gray-400"
518
+ >${formatDurationCompactMs(
519
+ runEntry.durationMs,
520
+ )}</span
521
+ >
522
+ <span class="text-gray-400"
523
+ >${formatTokenCount(runTokens)} tk</span
524
+ >
525
+ <span class="text-gray-500"
526
+ >${runEstimatedCost == null
527
+ ? "—"
528
+ : `~${formatCost(runEstimatedCost)}`}</span
529
+ >
530
+ </span>
531
+ </div>
532
+ </summary>
533
+ <div class="ac-history-body space-y-2 text-xs">
534
+ ${runEntry.summary
535
+ ? html`<div>
536
+ <span class="text-gray-500">Summary:</span>
537
+ ${runEntry.summary}
538
+ </div>`
539
+ : null}
540
+ ${runEntry.error
541
+ ? html`<div class="text-red-300">
542
+ <span class="text-gray-500">Error:</span>
543
+ ${runEntry.error}
544
+ </div>`
545
+ : null}
546
+ <div class="ac-surface-inset rounded-lg p-2.5 space-y-1.5">
547
+ <div class="text-gray-500">
548
+ Model:
549
+ <span class="text-gray-300 font-mono"
550
+ >${runEntry.model || "—"}</span
551
+ >
552
+ ${runEntry.sessionKey
553
+ ? html` | Session:
554
+ <span class="text-gray-300 font-mono"
555
+ >${runEntry.sessionKey}</span
556
+ >`
557
+ : null}
558
+ </div>
559
+ <div class="text-gray-500">
560
+ Usage:
561
+ <span class="text-gray-300"
562
+ >${formatTokenCount(runInputTokens)} in</span
563
+ >
564
+ <span class="text-gray-600">•</span>
565
+ <span class="text-gray-300"
566
+ >${formatTokenCount(runOutputTokens)} out</span
567
+ >
568
+ <span class="text-gray-600">•</span>
569
+ <span class="text-gray-300"
570
+ >${formatTokenCount(runTokens)} tk</span
571
+ >
572
+ <span class="text-gray-600">•</span>
573
+ <span class="text-gray-300"
574
+ >${runEstimatedCost == null
575
+ ? "—"
576
+ : `~${formatCost(runEstimatedCost)}`}</span
577
+ >
578
+ </div>
579
+ </div>
580
+ <div>
581
+ <button
582
+ type="button"
583
+ class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
584
+ onclick=${() => onSelectJob(runEntry.jobId)}
585
+ >
586
+ Open ${runEntry.jobName || runEntry.jobId}
587
+ </button>
588
+ </div>
589
+ </div>
590
+ </details>
591
+ `;
592
+ })}
593
+ </div>
594
+ `}
595
+ </section>
596
+ </div>
597
+ </div>
598
+ `;
599
+ };