@chrysb/alphaclaw 0.6.2-beta.5 → 0.6.2-beta.6

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.
@@ -3,13 +3,13 @@ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  buildCronOptimizationWarnings,
6
- formatRelativeMs,
7
6
  formatTokenCount,
8
7
  getNextScheduledRunAcrossJobs,
9
8
  } from "./cron-helpers.js";
10
9
  import { CronCalendar } from "./cron-calendar.js";
11
10
  import { CronRunsTrendCard } from "./cron-runs-trend-card.js";
12
11
  import { CronRunHistoryPanel } from "./cron-run-history-panel.js";
12
+ import { CronInsightsPanel } from "./cron-insights-panel.js";
13
13
  import { SummaryStatCard } from "../summary-stat-card.js";
14
14
  import { ErrorWarningLineIcon } from "../icons.js";
15
15
 
@@ -17,6 +17,7 @@ const html = htm.bind(h);
17
17
  const kRecentRunFetchLimit = 100;
18
18
  const kRecentRunRowsLimit = 20;
19
19
  const kRecentRunCollapseThreshold = 5;
20
+ const kTrendRange24h = "24h";
20
21
  const kTrendRange7d = "7d";
21
22
  const kTrendRange30d = "30d";
22
23
  const kTrendQueryStartKey = "trendStart";
@@ -136,16 +137,21 @@ const readTrendFilterFromHash = () => {
136
137
  const { params } = getHashRouteParts();
137
138
  const startMs = Number(params.get(kTrendQueryStartKey) || 0);
138
139
  const endMs = Number(params.get(kTrendQueryEndKey) || 0);
139
- const range = String(params.get(kTrendQueryRangeKey) || kTrendRange7d);
140
+ const range = String(params.get(kTrendQueryRangeKey) || kTrendRange24h);
140
141
  const label = String(params.get(kTrendQueryLabelKey) || "");
141
- const hasValidRange = range === kTrendRange7d || range === kTrendRange30d;
142
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
142
+ const hasValidRange =
143
+ range === kTrendRange24h || range === kTrendRange7d || range === kTrendRange30d;
144
+ if (
145
+ !Number.isFinite(startMs) ||
146
+ !Number.isFinite(endMs) ||
147
+ endMs <= startMs
148
+ ) {
143
149
  return null;
144
150
  }
145
151
  return {
146
152
  startMs,
147
153
  endMs,
148
- range: hasValidRange ? range : kTrendRange7d,
154
+ range: hasValidRange ? range : kTrendRange24h,
149
155
  label: label || "selected period",
150
156
  };
151
157
  };
@@ -162,14 +168,17 @@ const writeTrendFilterToHash = (filterValue = null) => {
162
168
  params.set(kTrendQueryEndKey, String(Number(filterValue.endMs || 0)));
163
169
  params.set(
164
170
  kTrendQueryRangeKey,
165
- filterValue.range === kTrendRange30d ? kTrendRange30d : kTrendRange7d,
171
+ filterValue.range === kTrendRange30d
172
+ ? kTrendRange30d
173
+ : filterValue.range === kTrendRange7d
174
+ ? kTrendRange7d
175
+ : kTrendRange24h,
166
176
  );
167
177
  params.set(kTrendQueryLabelKey, String(filterValue.label || ""));
168
178
  }
169
179
  const nextQuery = params.toString();
170
180
  const nextHash = nextQuery ? `#${pathPart}?${nextQuery}` : `#${pathPart}`;
171
- const nextUrl =
172
- `${window.location.pathname}${window.location.search}${nextHash}`;
181
+ const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
173
182
  window.history.replaceState(window.history.state, "", nextUrl);
174
183
  };
175
184
 
@@ -195,12 +204,20 @@ export const CronOverview = ({
195
204
  if (!selectedTrendBucketFilter) return recentRuns;
196
205
  const startMs = Number(selectedTrendBucketFilter?.startMs || 0);
197
206
  const endMs = Number(selectedTrendBucketFilter?.endMs || 0);
198
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
207
+ if (
208
+ !Number.isFinite(startMs) ||
209
+ !Number.isFinite(endMs) ||
210
+ endMs <= startMs
211
+ ) {
199
212
  return recentRuns;
200
213
  }
201
214
  return recentRuns.filter((entry) => {
202
215
  const timestampMs = Number(entry?.ts || 0);
203
- return Number.isFinite(timestampMs) && timestampMs >= startMs && timestampMs < endMs;
216
+ return (
217
+ Number.isFinite(timestampMs) &&
218
+ timestampMs >= startMs &&
219
+ timestampMs < endMs
220
+ );
204
221
  });
205
222
  }, [recentRuns, selectedTrendBucketFilter]);
206
223
  const filteredRecentRuns = useMemo(
@@ -218,9 +235,12 @@ export const CronOverview = ({
218
235
  () => buildCollapsedRunRows(filteredRecentRuns),
219
236
  [filteredRecentRuns],
220
237
  );
221
- const initialTrendRange = selectedTrendBucketFilter?.range === kTrendRange30d
222
- ? kTrendRange30d
223
- : kTrendRange7d;
238
+ const initialTrendRange =
239
+ selectedTrendBucketFilter?.range === kTrendRange30d
240
+ ? kTrendRange30d
241
+ : selectedTrendBucketFilter?.range === kTrendRange7d
242
+ ? kTrendRange7d
243
+ : kTrendRange24h;
224
244
  useEffect(() => {
225
245
  writeTrendFilterToHash(selectedTrendBucketFilter);
226
246
  }, [selectedTrendBucketFilter]);
@@ -228,7 +248,7 @@ export const CronOverview = ({
228
248
  return html`
229
249
  <div class="cron-detail-scroll">
230
250
  <div class="cron-detail-content">
231
- <div class="grid grid-cols-1 md:grid-cols-4 gap-3">
251
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
232
252
  <${SummaryStatCard}
233
253
  title="Total jobs"
234
254
  value=${jobs.length}
@@ -244,11 +264,6 @@ export const CronOverview = ({
244
264
  value=${disabledCount}
245
265
  monospace=${true}
246
266
  />
247
- <${SummaryStatCard}
248
- title="Next scheduled run"
249
- value=${nextRunMs ? formatRelativeMs(nextRunMs) : "—"}
250
- valueClassName="text-sm font-medium text-gray-200 leading-snug"
251
- />
252
267
  </div>
253
268
 
254
269
  <section class="bg-surface border border-border rounded-xl px-4 py-3">
@@ -320,6 +335,12 @@ export const CronOverview = ({
320
335
  onBucketFilterChange=${setSelectedTrendBucketFilter}
321
336
  />
322
337
 
338
+ <${CronInsightsPanel}
339
+ jobs=${jobs}
340
+ bulkRunsByJobId=${bulkRunsByJobId}
341
+ onSelectJob=${onSelectJob}
342
+ />
343
+
323
344
  <${CronRunHistoryPanel}
324
345
  entryCountLabel=${`${formatTokenCount(filteredRecentRuns.length)} entries`}
325
346
  primaryFilterOptions=${kRunStatusFilterOptions}
@@ -0,0 +1,173 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { EditorSurface } from "../file-viewer/editor-surface.js";
5
+ import { countTextLines, shouldUseSimpleEditorMode } from "../file-viewer/utils.js";
6
+ import {
7
+ kLargeFileSimpleEditorCharThreshold,
8
+ kLargeFileSimpleEditorLineThreshold,
9
+ } from "../file-viewer/constants.js";
10
+ import { useEditorLineNumberSync } from "../file-viewer/use-editor-line-number-sync.js";
11
+ import { highlightEditorLines } from "../../lib/syntax-highlighters/index.js";
12
+ import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
13
+
14
+ const html = htm.bind(h);
15
+ const kCronPromptEditorHeightUiSettingKey = "cronPromptEditorHeightPx";
16
+ const kCronPromptEditorDefaultHeightPx = 280;
17
+ const kCronPromptEditorMinHeightPx = 180;
18
+
19
+ const clampPromptEditorHeight = (value) => {
20
+ const parsed = Number(value);
21
+ const normalized = Number.isFinite(parsed)
22
+ ? Math.round(parsed)
23
+ : kCronPromptEditorDefaultHeightPx;
24
+ return Math.max(kCronPromptEditorMinHeightPx, normalized);
25
+ };
26
+
27
+ const readCssHeightPx = (element) => {
28
+ if (!element) return 0;
29
+ const computedHeight = Number.parseFloat(
30
+ window.getComputedStyle(element).height || "0",
31
+ );
32
+ return Number.isFinite(computedHeight) ? computedHeight : 0;
33
+ };
34
+
35
+ export const CronPromptEditor = ({
36
+ promptValue = "",
37
+ savedPromptValue = "",
38
+ onChangePrompt = () => {},
39
+ onSaveChanges = () => {},
40
+ }) => {
41
+ const promptEditorShellRef = useRef(null);
42
+ const editorTextareaRef = useRef(null);
43
+ const editorLineNumbersRef = useRef(null);
44
+ const editorLineNumberRowRefs = useRef([]);
45
+ const editorHighlightRef = useRef(null);
46
+ const editorHighlightLineRefs = useRef([]);
47
+ const [promptEditorHeightPx, setPromptEditorHeightPx] = useState(() => {
48
+ const settings = readUiSettings();
49
+ return clampPromptEditorHeight(
50
+ settings?.[kCronPromptEditorHeightUiSettingKey],
51
+ );
52
+ });
53
+
54
+ const lineCount = countTextLines(promptValue);
55
+ const shouldUseHighlightedEditor = !shouldUseSimpleEditorMode({
56
+ contentLength: promptValue.length,
57
+ lineCount,
58
+ charThreshold: kLargeFileSimpleEditorCharThreshold,
59
+ lineThreshold: kLargeFileSimpleEditorLineThreshold,
60
+ });
61
+ const highlightedEditorLines = useMemo(
62
+ () =>
63
+ shouldUseHighlightedEditor
64
+ ? highlightEditorLines(promptValue, "markdown")
65
+ : [],
66
+ [promptValue, shouldUseHighlightedEditor],
67
+ );
68
+ const editorLineCount = Math.max(
69
+ lineCount,
70
+ Array.isArray(highlightedEditorLines) ? highlightedEditorLines.length : 0,
71
+ );
72
+ const editorLineNumbers = useMemo(
73
+ () => Array.from({ length: editorLineCount }, (_, index) => index + 1),
74
+ [editorLineCount],
75
+ );
76
+ const isDirty = promptValue !== savedPromptValue;
77
+
78
+ useEditorLineNumberSync({
79
+ enabled: shouldUseHighlightedEditor,
80
+ syncKey: `${promptValue.length}:${highlightedEditorLines.length}`,
81
+ editorLineNumberRowRefs,
82
+ editorHighlightLineRefs,
83
+ });
84
+
85
+ const handleEditorScroll = (event) => {
86
+ const scrollTop = event.currentTarget.scrollTop;
87
+ if (editorLineNumbersRef.current)
88
+ editorLineNumbersRef.current.scrollTop = scrollTop;
89
+ if (editorHighlightRef.current) {
90
+ editorHighlightRef.current.scrollTop = scrollTop;
91
+ }
92
+ };
93
+
94
+ const handleEditorKeyDown = (event) => {
95
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
96
+ event.preventDefault();
97
+ onSaveChanges();
98
+ }
99
+ if (event.key === "Tab") {
100
+ event.preventDefault();
101
+ const textarea = editorTextareaRef.current;
102
+ if (!textarea) return;
103
+ const start = textarea.selectionStart;
104
+ const end = textarea.selectionEnd;
105
+ const nextValue = `${promptValue.slice(0, start)} ${promptValue.slice(end)}`;
106
+ onChangePrompt(nextValue);
107
+ window.requestAnimationFrame(() => {
108
+ textarea.selectionStart = start + 2;
109
+ textarea.selectionEnd = start + 2;
110
+ });
111
+ }
112
+ };
113
+
114
+ useEffect(() => {
115
+ const shellElement = promptEditorShellRef.current;
116
+ if (!shellElement || typeof ResizeObserver === "undefined") return () => {};
117
+
118
+ let saveTimer = null;
119
+ const observer = new ResizeObserver((entries) => {
120
+ const entry = entries?.[0];
121
+ const nextHeight = clampPromptEditorHeight(readCssHeightPx(entry?.target));
122
+ setPromptEditorHeightPx((currentValue) =>
123
+ Math.abs(currentValue - nextHeight) >= 1 ? nextHeight : currentValue,
124
+ );
125
+ if (saveTimer) window.clearTimeout(saveTimer);
126
+ saveTimer = window.setTimeout(() => {
127
+ const settings = readUiSettings();
128
+ settings[kCronPromptEditorHeightUiSettingKey] = nextHeight;
129
+ writeUiSettings(settings);
130
+ }, 120);
131
+ });
132
+ observer.observe(shellElement);
133
+ return () => {
134
+ observer.disconnect();
135
+ if (saveTimer) window.clearTimeout(saveTimer);
136
+ };
137
+ }, []);
138
+
139
+ return html`
140
+ <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
141
+ <div class="flex items-center justify-between gap-2">
142
+ <h3 class="card-label inline-flex items-center gap-1.5">
143
+ Prompt
144
+ ${isDirty ? html`<span class="file-viewer-dirty-dot"></span>` : null}
145
+ </h3>
146
+ </div>
147
+ <div
148
+ class="cron-prompt-editor-shell"
149
+ ref=${promptEditorShellRef}
150
+ style=${{ height: `${promptEditorHeightPx}px` }}
151
+ >
152
+ <${EditorSurface}
153
+ editorShellClassName="file-viewer-editor-shell"
154
+ editorLineNumbers=${editorLineNumbers}
155
+ editorLineNumbersRef=${editorLineNumbersRef}
156
+ editorLineNumberRowRefs=${editorLineNumberRowRefs}
157
+ shouldUseHighlightedEditor=${shouldUseHighlightedEditor}
158
+ highlightedEditorLines=${highlightedEditorLines}
159
+ editorHighlightRef=${editorHighlightRef}
160
+ editorHighlightLineRefs=${editorHighlightLineRefs}
161
+ editorTextareaRef=${editorTextareaRef}
162
+ renderContent=${promptValue}
163
+ handleContentInput=${(event) => onChangePrompt(event.target.value)}
164
+ handleEditorKeyDown=${handleEditorKeyDown}
165
+ handleEditorScroll=${handleEditorScroll}
166
+ handleEditorSelectionChange=${() => {}}
167
+ isEditBlocked=${false}
168
+ isPreviewOnly=${false}
169
+ />
170
+ </div>
171
+ </section>
172
+ `;
173
+ };
@@ -17,7 +17,8 @@ const runStatusClassName = (status = "") => {
17
17
  if (normalized === "skipped") return "text-yellow-300";
18
18
  return "text-gray-400";
19
19
  };
20
- const runDeliveryLabel = (run) => String(run?.deliveryStatus || "not-requested");
20
+ const runDeliveryLabel = (run) =>
21
+ String(run?.deliveryStatus || "not-requested");
21
22
  const getRunEstimatedCost = (runEntry = {}) => {
22
23
  const parsed = Number(runEntry?.estimatedCost);
23
24
  return Number.isFinite(parsed) ? parsed : null;
@@ -26,7 +27,10 @@ const formatOverviewTimestamp = (timestampMs) =>
26
27
  formatLocaleDateTimeWithTodayTime(timestampMs, {
27
28
  fallback: "—",
28
29
  valueIsEpochMs: true,
29
- }).replace(/\s([AP])M\b/g, (_, marker) => `${String(marker || "").toLowerCase()}m`);
30
+ }).replace(
31
+ /\s([AP])M\b/g,
32
+ (_, marker) => `${String(marker || "").toLowerCase()}m`,
33
+ );
30
34
  const formatDetailTimestamp = (timestampMs) =>
31
35
  formatLocaleDateTimeWithTodayTime(timestampMs, {
32
36
  fallback: "—",
@@ -36,11 +40,7 @@ const formatRowTimestamp = (timestampMs, variant = "overview") =>
36
40
  variant === "detail"
37
41
  ? formatDetailTimestamp(timestampMs)
38
42
  : formatOverviewTimestamp(timestampMs);
39
- const renderCollapsedGroupRow = ({
40
- row,
41
- rowIndex,
42
- onSelectJob = () => {},
43
- }) => {
43
+ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
44
44
  const statusSummary = Object.entries(row.statusCounts || {})
45
45
  .map(([status, count]) => `${status}: ${count}`)
46
46
  .join(" • ");
@@ -52,17 +52,10 @@ const renderCollapsedGroupRow = ({
52
52
  >
53
53
  <summary class="ac-history-summary">
54
54
  <div class="ac-history-summary-row">
55
- <span
56
- class="inline-flex items-center gap-2 min-w-0"
57
- >
58
- <span
59
- class="ac-history-toggle shrink-0"
60
- aria-hidden="true"
61
- >▸</span
62
- >
55
+ <span class="inline-flex items-center gap-2 min-w-0">
56
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
63
57
  <span class="truncate text-xs text-gray-300">
64
- ${row.jobName} -
65
- ${formatTokenCount(row.count)} runs -
58
+ ${row.jobName} - ${formatTokenCount(row.count)} runs -
66
59
  ${timeRangeLabel}
67
60
  </span>
68
61
  </span>
@@ -70,12 +63,10 @@ const renderCollapsedGroupRow = ({
70
63
  </summary>
71
64
  <div class="ac-history-body space-y-2 text-xs">
72
65
  <div class="text-gray-500">
73
- ${formatTokenCount(row.count)} consecutive runs
74
- collapsed (${timeRangeLabel})
75
- </div>
76
- <div class="text-gray-500">
77
- Statuses: ${statusSummary}
66
+ ${formatTokenCount(row.count)} consecutive runs collapsed
67
+ (${timeRangeLabel})
78
68
  </div>
69
+ <div class="text-gray-500">Statuses: ${statusSummary}</div>
79
70
  ${row?.jobId
80
71
  ? html`
81
72
  <div>
@@ -103,9 +94,15 @@ const renderEntryRow = ({
103
94
  const runEntry = row?.entry || row || {};
104
95
  const runUsage = runEntry?.usage || {};
105
96
  const runStatus = String(runEntry?.status || "unknown");
106
- const runInputTokens = Number(runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0);
107
- const runOutputTokens = Number(runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0);
108
- const runTokens = Number(runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0);
97
+ const runInputTokens = Number(
98
+ runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
99
+ );
100
+ const runOutputTokens = Number(
101
+ runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,
102
+ );
103
+ const runTokens = Number(
104
+ runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0,
105
+ );
109
106
  const runEstimatedCost = getRunEstimatedCost(runEntry);
110
107
  const runTitle = String(runEntry?.jobName || "").trim();
111
108
  const hasRunTitle = runTitle.length > 0;
@@ -117,14 +114,8 @@ const renderEntryRow = ({
117
114
  >
118
115
  <summary class="ac-history-summary">
119
116
  <div class="ac-history-summary-row">
120
- <span
121
- class="inline-flex items-center gap-2 min-w-0"
122
- >
123
- <span
124
- class="ac-history-toggle shrink-0"
125
- aria-hidden="true"
126
- >▸</span
127
- >
117
+ <span class="inline-flex items-center gap-2 min-w-0">
118
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
128
119
  ${isDetail
129
120
  ? html`
130
121
  <span class="truncate text-xs text-gray-300">
@@ -134,7 +125,9 @@ const renderEntryRow = ({
134
125
  : hasRunTitle
135
126
  ? html`
136
127
  <span class="inline-flex items-center gap-2 min-w-0">
137
- <span class="truncate text-xs text-gray-300">${runTitle}</span>
128
+ <span class="truncate text-xs text-gray-300"
129
+ >${runTitle}</span
130
+ >
138
131
  <span class="text-xs text-gray-500 shrink-0">
139
132
  ${formatRowTimestamp(runEntry.ts, variant)}
140
133
  </span>
@@ -147,17 +140,21 @@ const renderEntryRow = ({
147
140
  </span>
148
141
  `}
149
142
  </span>
150
- <span
151
- class="inline-flex items-center gap-3 shrink-0 text-xs"
152
- >
143
+ <span class="inline-flex items-center gap-3 shrink-0 text-xs">
153
144
  <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
154
- <span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
145
+ <span class="text-gray-400"
146
+ >${formatDurationCompactMs(runEntry.durationMs)}</span
147
+ >
155
148
  <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
156
149
  ${isDetail
157
- ? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
150
+ ? html`<span class="text-gray-500"
151
+ >${runDeliveryLabel(runEntry)}</span
152
+ >`
158
153
  : html`
159
154
  <span class="text-gray-500"
160
- >${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}</span
155
+ >${runEstimatedCost == null
156
+ ? "—"
157
+ : `~${formatCost(runEstimatedCost)}`}</span
161
158
  >
162
159
  `}
163
160
  </span>
@@ -165,36 +162,50 @@ const renderEntryRow = ({
165
162
  </summary>
166
163
  <div class="ac-history-body space-y-2 text-xs">
167
164
  ${runEntry.summary
168
- ? html`<div><span class="text-gray-500">Summary:</span> ${runEntry.summary}</div>`
165
+ ? html`<div>
166
+ <span class="text-gray-500">Summary:</span> ${runEntry.summary}
167
+ </div>`
169
168
  : null}
170
169
  ${runEntry.error
171
- ? html`<div class="text-red-300"><span class="text-gray-500">Error:</span> ${runEntry.error}</div>`
170
+ ? html`<div class="text-red-300">
171
+ <span class="text-gray-500">Error:</span> ${runEntry.error}
172
+ </div>`
172
173
  : null}
173
174
  <div class="ac-surface-inset rounded-lg p-2.5 space-y-1.5">
174
175
  <div class="text-gray-500">
175
- Model:
176
- <span class="text-gray-300 font-mono">${runEntry.model || "—"}</span>
176
+ Model:
177
+ <span class="text-gray-300 font-mono"
178
+ >${runEntry.model || "—"}</span
179
+ >
177
180
  </div>
178
181
  <div class="text-gray-500">
179
- Session:
180
- <span class="text-gray-300 font-mono">${runEntry.sessionKey || "—"}</span>
182
+ Session:
183
+ <span class="text-gray-300 font-mono"
184
+ >${runEntry.sessionKey || "—"}</span
185
+ >
181
186
  </div>
182
187
  <div class="text-gray-500">
183
- Tokens in:
184
- <span class="text-gray-300">${formatTokenCount(runInputTokens)}</span>
188
+ Tokens in:
189
+ <span class="text-gray-300"
190
+ >${formatTokenCount(runInputTokens)}</span
191
+ >
185
192
  </div>
186
193
  <div class="text-gray-500">
187
- Tokens out:
188
- <span class="text-gray-300">${formatTokenCount(runOutputTokens)}</span>
194
+ Tokens out:
195
+ <span class="text-gray-300"
196
+ >${formatTokenCount(runOutputTokens)}</span
197
+ >
189
198
  </div>
190
199
  <div class="text-gray-500">
191
- Total tokens:
200
+ Total tokens:
192
201
  <span class="text-gray-300">${formatTokenCount(runTokens)}</span>
193
202
  </div>
194
203
  <div class="text-gray-500">
195
- Total cost:
204
+ Total cost:
196
205
  <span class="text-gray-300">
197
- ${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
206
+ ${runEstimatedCost == null
207
+ ? "—"
208
+ : `~${formatCost(runEstimatedCost)}`}
198
209
  </span>
199
210
  </div>
200
211
  </div>
@@ -236,7 +247,7 @@ export const CronRunHistoryPanel = ({
236
247
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
237
248
  <div class="flex items-start justify-between gap-3">
238
249
  <div class="inline-flex items-center gap-3">
239
- <h3 class="card-label card-label-bright">Run history</h3>
250
+ <h3 class="card-label card-label-bright">Recent runs</h3>
240
251
  <div class="text-xs text-gray-500">${entryCountLabel}</div>
241
252
  </div>
242
253
  <div class="shrink-0 inline-flex items-center gap-2">
@@ -245,7 +256,8 @@ export const CronRunHistoryPanel = ({
245
256
  value=${primaryFilterValue}
246
257
  onChange=${onChangePrimaryFilter}
247
258
  />
248
- ${Array.isArray(secondaryFilterOptions) && secondaryFilterOptions.length > 0
259
+ ${Array.isArray(secondaryFilterOptions) &&
260
+ secondaryFilterOptions.length > 0
249
261
  ? html`
250
262
  <${SegmentedControl}
251
263
  options=${secondaryFilterOptions}
@@ -288,7 +300,8 @@ export const CronRunHistoryPanel = ({
288
300
  variant,
289
301
  onSelectJob,
290
302
  showOpenJobButton,
291
- }))}
303
+ }),
304
+ )}
292
305
  </div>
293
306
  `}
294
307
  ${footer}
@@ -6,10 +6,12 @@ import { formatCost } from "./cron-helpers.js";
6
6
 
7
7
  const html = htm.bind(h);
8
8
 
9
+ const kRange24h = "24h";
9
10
  const kRange7d = "7d";
10
11
  const kRange30d = "30d";
11
12
 
12
13
  const kRanges = [
14
+ { label: "24h", value: kRange24h },
13
15
  { label: "7d", value: kRange7d },
14
16
  { label: "30d", value: kRange30d },
15
17
  ];
@@ -27,6 +29,18 @@ const addLocalDaysMs = (valueMs, dayCount = 0) => {
27
29
  };
28
30
 
29
31
  const getBucketConfig = (range = kRange7d) => {
32
+ if (range === kRange24h) {
33
+ return {
34
+ bucketCount: 24,
35
+ bucketMs: 60 * 60 * 1000,
36
+ formatLabel: (valueMs) =>
37
+ new Date(valueMs).toLocaleTimeString([], {
38
+ hour: "numeric",
39
+ }),
40
+ showLabel: (_, index, total) => index % 3 === 0 || index === total - 1,
41
+ alignToLocalDay: false,
42
+ };
43
+ }
30
44
  if (range === kRange30d) {
31
45
  return {
32
46
  bucketCount: 30,
@@ -141,14 +155,18 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
141
155
 
142
156
  export const CronRunsTrendCard = ({
143
157
  bulkRunsByJobId = {},
144
- initialRange = kRange7d,
158
+ initialRange = kRange24h,
145
159
  selectedBucketFilter = null,
146
160
  onBucketFilterChange = () => {},
147
161
  }) => {
148
162
  const chartCanvasRef = useRef(null);
149
163
  const chartInstanceRef = useRef(null);
150
164
  const [range, setRange] = useState(
151
- initialRange === kRange30d ? kRange30d : kRange7d,
165
+ initialRange === kRange30d
166
+ ? kRange30d
167
+ : initialRange === kRange7d
168
+ ? kRange7d
169
+ : kRange24h,
152
170
  );
153
171
  const trend = useMemo(
154
172
  () => buildTrendData({ bulkRunsByJobId, nowMs: Date.now(), range }),