@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
@@ -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
+ };
@@ -5,7 +5,12 @@ import {
5
5
  formatDurationCompactMs,
6
6
  formatLocaleDateTimeWithTodayTime,
7
7
  } from "../../lib/format.js";
8
- import { formatCost, formatTokenCount } from "./cron-helpers.js";
8
+ import {
9
+ formatCost,
10
+ formatTokenCount,
11
+ getCronRunEstimatedCost,
12
+ getCronRunTotalTokens,
13
+ } from "./cron-helpers.js";
9
14
 
10
15
  const html = htm.bind(h);
11
16
  const runStatusClassName = (status = "") => {
@@ -17,16 +22,16 @@ const runStatusClassName = (status = "") => {
17
22
  if (normalized === "skipped") return "text-yellow-300";
18
23
  return "text-gray-400";
19
24
  };
20
- const runDeliveryLabel = (run) => String(run?.deliveryStatus || "not-requested");
21
- const getRunEstimatedCost = (runEntry = {}) => {
22
- const parsed = Number(runEntry?.estimatedCost);
23
- return Number.isFinite(parsed) ? parsed : null;
24
- };
25
+ const runDeliveryLabel = (run) =>
26
+ String(run?.deliveryStatus || "not-requested");
25
27
  const formatOverviewTimestamp = (timestampMs) =>
26
28
  formatLocaleDateTimeWithTodayTime(timestampMs, {
27
29
  fallback: "—",
28
30
  valueIsEpochMs: true,
29
- }).replace(/\s([AP])M\b/g, (_, marker) => `${String(marker || "").toLowerCase()}m`);
31
+ }).replace(
32
+ /\s([AP])M\b/g,
33
+ (_, marker) => `${String(marker || "").toLowerCase()}m`,
34
+ );
30
35
  const formatDetailTimestamp = (timestampMs) =>
31
36
  formatLocaleDateTimeWithTodayTime(timestampMs, {
32
37
  fallback: "—",
@@ -36,11 +41,7 @@ const formatRowTimestamp = (timestampMs, variant = "overview") =>
36
41
  variant === "detail"
37
42
  ? formatDetailTimestamp(timestampMs)
38
43
  : formatOverviewTimestamp(timestampMs);
39
- const renderCollapsedGroupRow = ({
40
- row,
41
- rowIndex,
42
- onSelectJob = () => {},
43
- }) => {
44
+ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
44
45
  const statusSummary = Object.entries(row.statusCounts || {})
45
46
  .map(([status, count]) => `${status}: ${count}`)
46
47
  .join(" • ");
@@ -52,17 +53,10 @@ const renderCollapsedGroupRow = ({
52
53
  >
53
54
  <summary class="ac-history-summary">
54
55
  <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
- >
56
+ <span class="inline-flex items-center gap-2 min-w-0">
57
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
63
58
  <span class="truncate text-xs text-gray-300">
64
- ${row.jobName} -
65
- ${formatTokenCount(row.count)} runs -
59
+ ${row.jobName} - ${formatTokenCount(row.count)} runs -
66
60
  ${timeRangeLabel}
67
61
  </span>
68
62
  </span>
@@ -70,12 +64,10 @@ const renderCollapsedGroupRow = ({
70
64
  </summary>
71
65
  <div class="ac-history-body space-y-2 text-xs">
72
66
  <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}
67
+ ${formatTokenCount(row.count)} consecutive runs collapsed
68
+ (${timeRangeLabel})
78
69
  </div>
70
+ <div class="text-gray-500">Statuses: ${statusSummary}</div>
79
71
  ${row?.jobId
80
72
  ? html`
81
73
  <div>
@@ -103,10 +95,14 @@ const renderEntryRow = ({
103
95
  const runEntry = row?.entry || row || {};
104
96
  const runUsage = runEntry?.usage || {};
105
97
  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);
109
- const runEstimatedCost = getRunEstimatedCost(runEntry);
98
+ const runInputTokens = Number(
99
+ runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
100
+ );
101
+ const runOutputTokens = Number(
102
+ runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,
103
+ );
104
+ const runTokens = getCronRunTotalTokens(runEntry);
105
+ const runEstimatedCost = getCronRunEstimatedCost(runEntry);
110
106
  const runTitle = String(runEntry?.jobName || "").trim();
111
107
  const hasRunTitle = runTitle.length > 0;
112
108
  const isDetail = variant === "detail";
@@ -117,14 +113,8 @@ const renderEntryRow = ({
117
113
  >
118
114
  <summary class="ac-history-summary">
119
115
  <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
- >
116
+ <span class="inline-flex items-center gap-2 min-w-0">
117
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
128
118
  ${isDetail
129
119
  ? html`
130
120
  <span class="truncate text-xs text-gray-300">
@@ -134,7 +124,9 @@ const renderEntryRow = ({
134
124
  : hasRunTitle
135
125
  ? html`
136
126
  <span class="inline-flex items-center gap-2 min-w-0">
137
- <span class="truncate text-xs text-gray-300">${runTitle}</span>
127
+ <span class="truncate text-xs text-gray-300"
128
+ >${runTitle}</span
129
+ >
138
130
  <span class="text-xs text-gray-500 shrink-0">
139
131
  ${formatRowTimestamp(runEntry.ts, variant)}
140
132
  </span>
@@ -147,17 +139,21 @@ const renderEntryRow = ({
147
139
  </span>
148
140
  `}
149
141
  </span>
150
- <span
151
- class="inline-flex items-center gap-3 shrink-0 text-xs"
152
- >
142
+ <span class="inline-flex items-center gap-3 shrink-0 text-xs">
153
143
  <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
154
- <span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
144
+ <span class="text-gray-400"
145
+ >${formatDurationCompactMs(runEntry.durationMs)}</span
146
+ >
155
147
  <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
156
148
  ${isDetail
157
- ? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
149
+ ? html`<span class="text-gray-500"
150
+ >${runDeliveryLabel(runEntry)}</span
151
+ >`
158
152
  : html`
159
153
  <span class="text-gray-500"
160
- >${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}</span
154
+ >${runEstimatedCost == null
155
+ ? "—"
156
+ : `~${formatCost(runEstimatedCost)}`}</span
161
157
  >
162
158
  `}
163
159
  </span>
@@ -165,36 +161,50 @@ const renderEntryRow = ({
165
161
  </summary>
166
162
  <div class="ac-history-body space-y-2 text-xs">
167
163
  ${runEntry.summary
168
- ? html`<div><span class="text-gray-500">Summary:</span> ${runEntry.summary}</div>`
164
+ ? html`<div>
165
+ <span class="text-gray-500">Summary:</span> ${runEntry.summary}
166
+ </div>`
169
167
  : null}
170
168
  ${runEntry.error
171
- ? html`<div class="text-red-300"><span class="text-gray-500">Error:</span> ${runEntry.error}</div>`
169
+ ? html`<div class="text-red-300">
170
+ <span class="text-gray-500">Error:</span> ${runEntry.error}
171
+ </div>`
172
172
  : null}
173
173
  <div class="ac-surface-inset rounded-lg p-2.5 space-y-1.5">
174
174
  <div class="text-gray-500">
175
- Model:
176
- <span class="text-gray-300 font-mono">${runEntry.model || "—"}</span>
175
+ Model:
176
+ <span class="text-gray-300 font-mono"
177
+ >${runEntry.model || "—"}</span
178
+ >
177
179
  </div>
178
180
  <div class="text-gray-500">
179
- Session:
180
- <span class="text-gray-300 font-mono">${runEntry.sessionKey || "—"}</span>
181
+ Session:
182
+ <span class="text-gray-300 font-mono"
183
+ >${runEntry.sessionKey || "—"}</span
184
+ >
181
185
  </div>
182
186
  <div class="text-gray-500">
183
- Tokens in:
184
- <span class="text-gray-300">${formatTokenCount(runInputTokens)}</span>
187
+ Tokens in:
188
+ <span class="text-gray-300"
189
+ >${formatTokenCount(runInputTokens)}</span
190
+ >
185
191
  </div>
186
192
  <div class="text-gray-500">
187
- Tokens out:
188
- <span class="text-gray-300">${formatTokenCount(runOutputTokens)}</span>
193
+ Tokens out:
194
+ <span class="text-gray-300"
195
+ >${formatTokenCount(runOutputTokens)}</span
196
+ >
189
197
  </div>
190
198
  <div class="text-gray-500">
191
- Total tokens:
199
+ Total tokens:
192
200
  <span class="text-gray-300">${formatTokenCount(runTokens)}</span>
193
201
  </div>
194
202
  <div class="text-gray-500">
195
- Total cost:
203
+ Total cost:
196
204
  <span class="text-gray-300">
197
- ${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
205
+ ${runEstimatedCost == null
206
+ ? "—"
207
+ : `~${formatCost(runEstimatedCost)}`}
198
208
  </span>
199
209
  </div>
200
210
  </div>
@@ -236,7 +246,7 @@ export const CronRunHistoryPanel = ({
236
246
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
237
247
  <div class="flex items-start justify-between gap-3">
238
248
  <div class="inline-flex items-center gap-3">
239
- <h3 class="card-label card-label-bright">Run history</h3>
249
+ <h3 class="card-label card-label-bright">Recent runs</h3>
240
250
  <div class="text-xs text-gray-500">${entryCountLabel}</div>
241
251
  </div>
242
252
  <div class="shrink-0 inline-flex items-center gap-2">
@@ -245,7 +255,8 @@ export const CronRunHistoryPanel = ({
245
255
  value=${primaryFilterValue}
246
256
  onChange=${onChangePrimaryFilter}
247
257
  />
248
- ${Array.isArray(secondaryFilterOptions) && secondaryFilterOptions.length > 0
258
+ ${Array.isArray(secondaryFilterOptions) &&
259
+ secondaryFilterOptions.length > 0
249
260
  ? html`
250
261
  <${SegmentedControl}
251
262
  options=${secondaryFilterOptions}
@@ -288,7 +299,8 @@ export const CronRunHistoryPanel = ({
288
299
  variant,
289
300
  onSelectJob,
290
301
  showOpenJobButton,
291
- }))}
302
+ }),
303
+ )}
292
304
  </div>
293
305
  `}
294
306
  ${footer}
@@ -2,14 +2,16 @@ import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { SegmentedControl } from "../segmented-control.js";
5
- import { formatCost } from "./cron-helpers.js";
5
+ import { formatCost, getCronRunEstimatedCost } 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,
@@ -50,25 +64,6 @@ const getBucketConfig = (range = kRange7d) => {
50
64
  };
51
65
  };
52
66
 
53
- const getEstimatedCostForEntry = (entry = {}) => {
54
- const usage = entry?.usage || {};
55
- const candidates = [
56
- entry?.estimatedCost,
57
- entry?.estimated_cost,
58
- usage?.estimatedCost,
59
- usage?.estimated_cost,
60
- usage?.totalCost,
61
- usage?.total_cost,
62
- usage?.costUsd,
63
- usage?.cost,
64
- ];
65
- for (const candidate of candidates) {
66
- const numericValue = Number(candidate);
67
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
68
- }
69
- return null;
70
- };
71
-
72
67
  const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRange7d } = {}) => {
73
68
  const config = getBucketConfig(range);
74
69
  const safeNowMs = Number.isFinite(Number(nowMs)) ? Number(nowMs) : Date.now();
@@ -115,7 +110,7 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
115
110
  if (!Number.isFinite(Number(bucketIndex))) return;
116
111
  if (bucketIndex < 0 || bucketIndex >= config.bucketCount) return;
117
112
  points[bucketIndex][status] += 1;
118
- const estimatedCost = getEstimatedCostForEntry(entry);
113
+ const estimatedCost = getCronRunEstimatedCost(entry);
119
114
  if (estimatedCost != null) {
120
115
  points[bucketIndex].totalCost += estimatedCost;
121
116
  points[bucketIndex].costCount += 1;
@@ -141,14 +136,18 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
141
136
 
142
137
  export const CronRunsTrendCard = ({
143
138
  bulkRunsByJobId = {},
144
- initialRange = kRange7d,
139
+ initialRange = kRange24h,
145
140
  selectedBucketFilter = null,
146
141
  onBucketFilterChange = () => {},
147
142
  }) => {
148
143
  const chartCanvasRef = useRef(null);
149
144
  const chartInstanceRef = useRef(null);
150
145
  const [range, setRange] = useState(
151
- initialRange === kRange30d ? kRange30d : kRange7d,
146
+ initialRange === kRange30d
147
+ ? kRange30d
148
+ : initialRange === kRange7d
149
+ ? kRange7d
150
+ : kRange24h,
152
151
  );
153
152
  const trend = useMemo(
154
153
  () => buildTrendData({ bulkRunsByJobId, nowMs: Date.now(), range }),
@@ -287,6 +286,7 @@ export const CronRunsTrendCard = ({
287
286
  },
288
287
  plugins: {
289
288
  legend: {
289
+ position: "bottom",
290
290
  labels: {
291
291
  color: "rgba(209,213,219,1)",
292
292
  boxWidth: 10,
@@ -321,7 +321,7 @@ export const CronRunsTrendCard = ({
321
321
  return html`
322
322
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
323
323
  <div class="flex items-center justify-between gap-2">
324
- <h3 class="card-label cron-calendar-title">Run Outcome Trend</h3>
324
+ <h3 class="card-label cron-calendar-title">Run Outcomes</h3>
325
325
  <${SegmentedControl}
326
326
  options=${kRanges}
327
327
  value=${range}