@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
@@ -1,10 +1,20 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
3
8
  import htm from "https://esm.sh/htm";
4
- import { ActionButton } from "../action-button.js";
9
+ import { SegmentedControl } from "../segmented-control.js";
5
10
  import { Tooltip } from "../tooltip.js";
6
- import { formatCost, formatTokenCount } from "./cron-helpers.js";
7
- import { formatCronScheduleLabel } from "./cron-helpers.js";
11
+ import {
12
+ formatCost,
13
+ formatCronScheduleLabel,
14
+ formatTokenCount,
15
+ getCronRunEstimatedCost,
16
+ getCronRunTotalTokens,
17
+ } from "./cron-helpers.js";
8
18
  import { readUiSettings, updateUiSettings } from "../../lib/ui-settings.js";
9
19
  import {
10
20
  classifyRepeatingJobs,
@@ -24,7 +34,8 @@ const formatHourLabel = (hourOfDay) => {
24
34
  });
25
35
  };
26
36
 
27
- const buildCellKey = (dayKey, hourOfDay) => `${String(dayKey || "")}:${hourOfDay}`;
37
+ const buildCellKey = (dayKey, hourOfDay) =>
38
+ `${String(dayKey || "")}:${hourOfDay}`;
28
39
  const toLocalDayKey = (valueMs) => {
29
40
  const dateValue = new Date(valueMs);
30
41
  const year = dateValue.getFullYear();
@@ -49,101 +60,107 @@ const slotStateClassName = ({
49
60
  const tierClassName = tierClassNameByKey[tokenTier] || tierClassNameByKey.low;
50
61
  if (!isPast) return `${tierClassName} cron-calendar-slot-upcoming`;
51
62
  if (mappedStatus === "ok") return `${tierClassName} cron-calendar-slot-ok`;
52
- if (mappedStatus === "error") return `${tierClassName} cron-calendar-slot-error`;
53
- if (mappedStatus === "skipped") return `${tierClassName} cron-calendar-slot-skipped`;
63
+ if (mappedStatus === "error")
64
+ return `${tierClassName} cron-calendar-slot-error`;
65
+ if (mappedStatus === "skipped")
66
+ return `${tierClassName} cron-calendar-slot-skipped`;
54
67
  return `${tierClassName} cron-calendar-slot-past`;
55
68
  };
56
69
 
57
70
  const renderLegend = () => html`
58
71
  <div class="cron-calendar-legend">
59
72
  <span class="cron-calendar-legend-label">Token intensity</span>
60
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-unknown">No usage</span>
61
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-low">Low</span>
62
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-medium">Medium</span>
63
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-high">High</span>
64
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-very-high">Very high</span>
73
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-unknown"
74
+ >No usage</span
75
+ >
76
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-low"
77
+ >Low</span
78
+ >
79
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-medium"
80
+ >Medium</span
81
+ >
82
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-high"
83
+ >High</span
84
+ >
85
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-very-high"
86
+ >Very high</span
87
+ >
65
88
  </div>
66
89
  `;
67
90
 
68
91
  const kNowRefreshMs = 60 * 1000;
69
92
  const kCalendarExpandedUiSettingKey = "cronCalendarExpanded";
93
+ const kCalendarViewUiSettingKey = "cronCalendarView";
94
+ const kCalendarViewUpcoming = "upcoming";
95
+ const kCalendarViewCalendar = "calendar";
96
+ const kCalendarViewOptions = [
97
+ { label: "Up next", value: kCalendarViewUpcoming },
98
+ { label: "Calendar", value: kCalendarViewCalendar },
99
+ ];
70
100
  const kRunWindow7dMs = 7 * 24 * 60 * 60 * 1000;
71
101
  const kSlotRunToleranceMs = 45 * 60 * 1000;
72
102
  const kUnknownTier = "unknown";
73
103
 
74
104
  const formatUpcomingTime = (timestampMs) => {
75
105
  const dateValue = new Date(timestampMs);
76
- return dateValue.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
77
- };
78
-
79
- const getRunTotalTokens = (entry = {}) => {
80
- const usage = entry?.usage || {};
81
- const candidates = [
82
- usage?.total_tokens,
83
- usage?.totalTokens,
84
- entry?.total_tokens,
85
- entry?.totalTokens,
86
- ];
87
- for (const candidate of candidates) {
88
- const numericValue = Number(candidate);
89
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
90
- }
91
- return 0;
92
- };
93
-
94
- const getRunEstimatedCost = (entry = {}) => {
95
- const usage = entry?.usage || {};
96
- const candidates = [
97
- entry?.estimatedCost,
98
- entry?.estimated_cost,
99
- usage?.estimatedCost,
100
- usage?.estimated_cost,
101
- usage?.totalCost,
102
- usage?.total_cost,
103
- usage?.costUsd,
104
- usage?.cost,
105
- ];
106
- for (const candidate of candidates) {
107
- const numericValue = Number(candidate);
108
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
109
- }
110
- return null;
106
+ return dateValue.toLocaleTimeString([], {
107
+ hour: "numeric",
108
+ minute: "2-digit",
109
+ });
111
110
  };
112
111
 
113
- const buildRunSummaryByJobId = ({ runsByJobId = {}, nowMs = Date.now() } = {}) => {
112
+ const buildRunSummaryByJobId = ({
113
+ runsByJobId = {},
114
+ nowMs = Date.now(),
115
+ } = {}) => {
114
116
  const cutoffMs = Number(nowMs || Date.now()) - kRunWindow7dMs;
115
- return Object.entries(runsByJobId || {}).reduce((accumulator, [jobId, runResult]) => {
116
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
117
- const recentEntries = entries.filter((entry) => {
118
- const timestampMs = Number(entry?.ts || 0);
119
- return Number.isFinite(timestampMs) && timestampMs >= cutoffMs && timestampMs <= nowMs;
120
- });
121
- const runCount = recentEntries.length;
122
- const totalTokens = recentEntries.reduce(
123
- (sum, entry) => sum + Number(getRunTotalTokens(entry) || 0),
124
- 0,
125
- );
126
- const totalCost = recentEntries.reduce((sum, entry) => {
127
- const cost = getRunEstimatedCost(entry);
128
- return sum + Number(cost == null ? 0 : cost);
129
- }, 0);
130
- accumulator[String(jobId || "")] = {
131
- runCount,
132
- totalTokens,
133
- totalCost,
134
- avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,
135
- avgCostPerRun: runCount > 0 ? totalCost / runCount : 0,
136
- };
137
- return accumulator;
138
- }, {});
117
+ return Object.entries(runsByJobId || {}).reduce(
118
+ (accumulator, [jobId, runResult]) => {
119
+ const entries = Array.isArray(runResult?.entries)
120
+ ? runResult.entries
121
+ : [];
122
+ const recentEntries = entries.filter((entry) => {
123
+ const timestampMs = Number(entry?.ts || 0);
124
+ return (
125
+ Number.isFinite(timestampMs) &&
126
+ timestampMs >= cutoffMs &&
127
+ timestampMs <= nowMs
128
+ );
129
+ });
130
+ const runCount = recentEntries.length;
131
+ const totalTokens = recentEntries.reduce(
132
+ (sum, entry) => sum + Number(getCronRunTotalTokens(entry) || 0),
133
+ 0,
134
+ );
135
+ const totalCost = recentEntries.reduce((sum, entry) => {
136
+ const cost = getCronRunEstimatedCost(entry);
137
+ return sum + Number(cost == null ? 0 : cost);
138
+ }, 0);
139
+ accumulator[String(jobId || "")] = {
140
+ runCount,
141
+ totalTokens,
142
+ totalCost,
143
+ avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,
144
+ avgCostPerRun: runCount > 0 ? totalCost / runCount : 0,
145
+ };
146
+ return accumulator;
147
+ },
148
+ {},
149
+ );
139
150
  };
140
151
 
141
- const mapRunsToSlots = ({ slots = [], runsByJobId = {}, nowMs = Date.now() } = {}) => {
152
+ const mapRunsToSlots = ({
153
+ slots = [],
154
+ runsByJobId = {},
155
+ nowMs = Date.now(),
156
+ } = {}) => {
142
157
  const runsBySlotKey = {};
143
158
  const consumedRunTimestampsByJobId = {};
144
159
  const runEntriesByJobId = Object.entries(runsByJobId || {}).reduce(
145
160
  (accumulator, [jobId, runResult]) => {
146
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
161
+ const entries = Array.isArray(runResult?.entries)
162
+ ? runResult.entries
163
+ : [];
147
164
  const normalizedEntries = entries
148
165
  .map((entry) => ({ ...entry, ts: Number(entry?.ts || 0) }))
149
166
  .filter((entry) => Number.isFinite(entry.ts) && entry.ts > 0)
@@ -205,14 +222,14 @@ const classifyTokenTier = ({
205
222
  } = {}) => {
206
223
  if (!enabled) return "disabled";
207
224
  const safeValue = Number(tokenValue || 0);
208
- if (!Number.isFinite(safeValue) || safeValue <= 0 || !thresholds) return kUnknownTier;
225
+ if (!Number.isFinite(safeValue) || safeValue <= 0 || !thresholds)
226
+ return kUnknownTier;
209
227
  if (safeValue <= thresholds.q1) return "low";
210
228
  if (safeValue <= thresholds.q2) return "medium";
211
229
  if (safeValue <= thresholds.p90) return "high";
212
230
  return "very-high";
213
231
  };
214
232
 
215
-
216
233
  const buildJobTooltipText = ({
217
234
  jobName = "",
218
235
  job = null,
@@ -223,18 +240,25 @@ const buildJobTooltipText = ({
223
240
  scheduledStatus = "",
224
241
  nowMs = Date.now(),
225
242
  } = {}) => {
226
- const isPastSlot = Number(scheduledAtMs || 0) > 0 && Number(scheduledAtMs || 0) <= nowMs;
243
+ const isPastSlot =
244
+ Number(scheduledAtMs || 0) > 0 && Number(scheduledAtMs || 0) <= nowMs;
227
245
  const runCount7d = Number(runSummary7d?.runCount || 0);
228
246
  const avgTokensPerRun7d = Number(runSummary7d?.avgTokensPerRun || 0);
229
247
  const avgCostPerRun7d = Number(runSummary7d?.avgCostPerRun || 0);
230
- const slotRunTokens = getRunTotalTokens(slotRun || {});
231
- const slotRunCost = getRunEstimatedCost(slotRun || {});
232
- const slotRunStatus = String(slotRun?.status || "").trim().toLowerCase();
248
+ const slotRunTokens = getCronRunTotalTokens(slotRun || {});
249
+ const slotRunCost = getCronRunEstimatedCost(slotRun || {});
250
+ const slotRunStatus = String(slotRun?.status || "")
251
+ .trim()
252
+ .toLowerCase();
233
253
 
234
254
  const lines = [String(jobName || "Job")];
235
255
  if (isPastSlot) {
236
- lines.push(`Run tokens: ${slotRun ? formatTokenCount(slotRunTokens) : "—"}`);
237
- lines.push(`Run cost: ${slotRunCost == null ? "—" : formatCost(slotRunCost)}`);
256
+ lines.push(
257
+ `Run tokens: ${slotRun ? formatTokenCount(slotRunTokens) : "—"}`,
258
+ );
259
+ lines.push(
260
+ `Run cost: ${slotRunCost == null ? "—" : formatCost(slotRunCost)}`,
261
+ );
238
262
  lines.push(`Run status: ${slotRunStatus || scheduledStatus || "unknown"}`);
239
263
  if (slotRun?.ts) {
240
264
  lines.push(
@@ -248,7 +272,9 @@ const buildJobTooltipText = ({
248
272
  lines.push(
249
273
  `Avg cost/run (last 7d): ${runCount7d > 0 ? formatCost(avgCostPerRun7d) : "—"}`,
250
274
  );
251
- lines.push(`Runs (last 7d): ${runCount7d > 0 ? formatTokenCount(runCount7d) : "none"}`);
275
+ lines.push(
276
+ `Runs (last 7d): ${runCount7d > 0 ? formatTokenCount(runCount7d) : "none"}`,
277
+ );
252
278
  }
253
279
 
254
280
  if (!isPastSlot && latestRun?.status) {
@@ -259,12 +285,15 @@ const buildJobTooltipText = ({
259
285
  lines.push("Latest run: none");
260
286
  }
261
287
  if (Number(job?.state?.runningAtMs || 0) > 0) {
262
- lines.push(`Current run: active (${new Date(Number(job.state.runningAtMs)).toLocaleString()})`);
288
+ lines.push(
289
+ `Current run: active (${new Date(Number(job.state.runningAtMs)).toLocaleString()})`,
290
+ );
263
291
  }
264
292
 
265
293
  if (scheduledAtMs > 0) {
266
294
  const slotLabel = new Date(scheduledAtMs).toLocaleString();
267
- const slotState = scheduledStatus || (scheduledAtMs <= Date.now() ? "past" : "upcoming");
295
+ const slotState =
296
+ scheduledStatus || (scheduledAtMs <= Date.now() ? "past" : "upcoming");
268
297
  lines.push(`Slot: ${slotState} (${slotLabel})`);
269
298
  }
270
299
  return lines.join("\n");
@@ -275,16 +304,29 @@ export const CronCalendar = ({
275
304
  runsByJobId = {},
276
305
  onSelectJob = () => {},
277
306
  }) => {
278
- const [expanded, setExpanded] = useState(() => {
307
+ const [calendarView, setCalendarView] = useState(() => {
279
308
  const settings = readUiSettings();
280
- return settings[kCalendarExpandedUiSettingKey] === true;
309
+ const savedView = String(
310
+ settings?.[kCalendarViewUiSettingKey] || "",
311
+ ).trim();
312
+ if (savedView === kCalendarViewCalendar) return kCalendarViewCalendar;
313
+ if (savedView === kCalendarViewUpcoming) return kCalendarViewUpcoming;
314
+ return settings[kCalendarExpandedUiSettingKey] === true
315
+ ? kCalendarViewCalendar
316
+ : kCalendarViewUpcoming;
281
317
  });
282
- const toggleExpanded = useCallback(() => {
283
- setExpanded((previous) => {
284
- const next = !previous;
285
- updateUiSettings((settings) => ({ ...settings, [kCalendarExpandedUiSettingKey]: next }));
286
- return next;
287
- });
318
+ const isCalendarView = calendarView === kCalendarViewCalendar;
319
+ const onChangeCalendarView = useCallback((nextValue) => {
320
+ const nextView =
321
+ nextValue === kCalendarViewCalendar
322
+ ? kCalendarViewCalendar
323
+ : kCalendarViewUpcoming;
324
+ setCalendarView(nextView);
325
+ updateUiSettings((settings) => ({
326
+ ...settings,
327
+ [kCalendarViewUiSettingKey]: nextView,
328
+ [kCalendarExpandedUiSettingKey]: nextView === kCalendarViewCalendar,
329
+ }));
288
330
  }, []);
289
331
 
290
332
  const [nowMs, setNowMs] = useState(() => Date.now());
@@ -306,7 +348,12 @@ export const CronCalendar = ({
306
348
  [scheduledJobs, nowMs],
307
349
  );
308
350
  const statusBySlotKey = useMemo(
309
- () => mapRunStatusesToSlots({ slots: timeline.slots, bulkRunsByJobId: runsByJobId, nowMs }),
351
+ () =>
352
+ mapRunStatusesToSlots({
353
+ slots: timeline.slots,
354
+ bulkRunsByJobId: runsByJobId,
355
+ nowMs,
356
+ }),
310
357
  [timeline.slots, runsByJobId, nowMs],
311
358
  );
312
359
  const jobById = useMemo(
@@ -320,14 +367,21 @@ export const CronCalendar = ({
320
367
  );
321
368
  const latestRunByJobId = useMemo(
322
369
  () =>
323
- Object.entries(runsByJobId || {}).reduce((accumulator, [jobId, runResult]) => {
324
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
325
- const latest = entries
326
- .filter((entry) => Number(entry?.ts || 0) > 0)
327
- .sort((left, right) => Number(right?.ts || 0) - Number(left?.ts || 0))[0];
328
- accumulator[jobId] = latest || null;
329
- return accumulator;
330
- }, {}),
370
+ Object.entries(runsByJobId || {}).reduce(
371
+ (accumulator, [jobId, runResult]) => {
372
+ const entries = Array.isArray(runResult?.entries)
373
+ ? runResult.entries
374
+ : [];
375
+ const latest = entries
376
+ .filter((entry) => Number(entry?.ts || 0) > 0)
377
+ .sort(
378
+ (left, right) => Number(right?.ts || 0) - Number(left?.ts || 0),
379
+ )[0];
380
+ accumulator[jobId] = latest || null;
381
+ return accumulator;
382
+ },
383
+ {},
384
+ ),
331
385
  [runsByJobId],
332
386
  );
333
387
  const runSummary7dByJobId = useMemo(
@@ -345,51 +399,72 @@ export const CronCalendar = ({
345
399
  if (!job || job.enabled === false) return;
346
400
  const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
347
401
  if (isPastSlot) {
348
- const slotRunTokens = getRunTotalTokens(runBySlotKey[slot.key] || {});
402
+ const slotRunTokens = getCronRunTotalTokens(runBySlotKey[slot.key] || {});
349
403
  if (slotRunTokens > 0) values.push(slotRunTokens);
350
404
  return;
351
405
  }
352
- const projectedAvgTokens = Number(runSummary7dByJobId[slot.jobId]?.avgTokensPerRun || 0);
406
+ const projectedAvgTokens = Number(
407
+ runSummary7dByJobId[slot.jobId]?.avgTokensPerRun || 0,
408
+ );
353
409
  if (projectedAvgTokens > 0) values.push(projectedAvgTokens);
354
410
  });
355
411
  repeatingJobs.forEach((job) => {
356
412
  const jobId = String(job?.id || "");
357
- const projectedAvgTokens = Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0);
413
+ const projectedAvgTokens = Number(
414
+ runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,
415
+ );
358
416
  if (projectedAvgTokens > 0) values.push(projectedAvgTokens);
359
417
  });
360
418
  return buildTierThresholds(values);
361
- }, [jobById, nowMs, repeatingJobs, runBySlotKey, runSummary7dByJobId, timeline.slots]);
362
- const getSlotTokenTier = useCallback((slot = null) => {
363
- const jobId = String(slot?.jobId || "");
364
- const job = jobById[jobId] || null;
365
- const enabled = job?.enabled !== false;
366
- const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
367
- if (isPastSlot) {
368
- const slotRunTokens = getRunTotalTokens(runBySlotKey[String(slot?.key || "")] || {});
419
+ }, [
420
+ jobById,
421
+ nowMs,
422
+ repeatingJobs,
423
+ runBySlotKey,
424
+ runSummary7dByJobId,
425
+ timeline.slots,
426
+ ]);
427
+ const getSlotTokenTier = useCallback(
428
+ (slot = null) => {
429
+ const jobId = String(slot?.jobId || "");
430
+ const job = jobById[jobId] || null;
431
+ const enabled = job?.enabled !== false;
432
+ const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
433
+ if (isPastSlot) {
434
+ const slotRunTokens = getCronRunTotalTokens(
435
+ runBySlotKey[String(slot?.key || "")] || {},
436
+ );
437
+ return classifyTokenTier({
438
+ enabled,
439
+ tokenValue: slotRunTokens,
440
+ thresholds: slotTierThresholds,
441
+ });
442
+ }
443
+ const projectedAvgTokens = Number(
444
+ runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,
445
+ );
369
446
  return classifyTokenTier({
370
447
  enabled,
371
- tokenValue: slotRunTokens,
448
+ tokenValue: projectedAvgTokens,
372
449
  thresholds: slotTierThresholds,
373
450
  });
374
- }
375
- const projectedAvgTokens = Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0);
376
- return classifyTokenTier({
377
- enabled,
378
- tokenValue: projectedAvgTokens,
379
- thresholds: slotTierThresholds,
380
- });
381
- }, [jobById, nowMs, runBySlotKey, runSummary7dByJobId, slotTierThresholds]);
382
- const getJobProjectedTier = useCallback((jobId = "") => {
383
- const job = jobById[jobId] || null;
384
- return classifyTokenTier({
385
- enabled: job?.enabled !== false,
386
- tokenValue: Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0),
387
- thresholds: slotTierThresholds,
388
- });
389
- }, [jobById, runSummary7dByJobId, slotTierThresholds]);
451
+ },
452
+ [jobById, nowMs, runBySlotKey, runSummary7dByJobId, slotTierThresholds],
453
+ );
454
+ const getJobProjectedTier = useCallback(
455
+ (jobId = "") => {
456
+ const job = jobById[jobId] || null;
457
+ return classifyTokenTier({
458
+ enabled: job?.enabled !== false,
459
+ tokenValue: Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0),
460
+ thresholds: slotTierThresholds,
461
+ });
462
+ },
463
+ [jobById, runSummary7dByJobId, slotTierThresholds],
464
+ );
390
465
 
391
466
  const upcomingSlots = useMemo(
392
- () => getUpcomingSlots({ slots: timeline.slots, nowMs }),
467
+ () => getUpcomingSlots({ slots: timeline.slots, nowMs, limit: 3 }),
393
468
  [timeline.slots, nowMs],
394
469
  );
395
470
 
@@ -418,7 +493,9 @@ export const CronCalendar = ({
418
493
  const renderCompactStrip = () => {
419
494
  return html`
420
495
  ${upcomingSlots.length === 0
421
- ? html`<div class="text-xs text-gray-500 py-1">No upcoming jobs in the next 24 hours.</div>`
496
+ ? html`<div class="text-xs text-gray-500 py-1">
497
+ No upcoming jobs in the next 24 hours.
498
+ </div>`
422
499
  : html`
423
500
  <div class="cron-calendar-compact-strip">
424
501
  ${upcomingSlots.map((slot) => {
@@ -460,8 +537,12 @@ export const CronCalendar = ({
460
537
  ? html`
461
538
  <button
462
539
  class="text-[11px] text-gray-500 hover:text-gray-300 self-center transition-colors"
463
- onClick=${toggleExpanded}
464
- >+${Math.max(0, totalUpcoming - upcomingSlots.length)} more this week</button>
540
+ onClick=${() =>
541
+ onChangeCalendarView(kCalendarViewCalendar)}
542
+ >
543
+ +${Math.max(0, totalUpcoming - upcomingSlots.length)} more
544
+ this week
545
+ </button>
465
546
  `
466
547
  : null}
467
548
  </div>
@@ -476,7 +557,9 @@ export const CronCalendar = ({
476
557
  </div>
477
558
 
478
559
  ${hourRows.length === 0
479
- ? html`<div class="text-sm text-gray-500">No scheduled jobs in this rolling window.</div>`
560
+ ? html`<div class="text-sm text-gray-500">
561
+ No scheduled jobs in this rolling window.
562
+ </div>`
480
563
  : html`
481
564
  <div class="cron-calendar-grid-wrap">
482
565
  <div class="cron-calendar-grid-header">
@@ -493,34 +576,41 @@ export const CronCalendar = ({
493
576
  )}
494
577
  </div>
495
578
  <div class="cron-calendar-grid-body">
496
- ${hourRows.map((hourOfDay) => html`
497
- <div key=${hourOfDay} class="cron-calendar-grid-row">
498
- <div class="cron-calendar-hour-cell">${formatHourLabel(hourOfDay)}</div>
499
- ${timeline.days.map((day) => {
500
- const cellKey = buildCellKey(day.dayKey, hourOfDay);
501
- const cellSlots = slotsByCellKey[cellKey] || [];
502
- const visibleSlots = cellSlots.slice(0, 3);
503
- const overflowCount = Math.max(0, cellSlots.length - visibleSlots.length);
504
- return html`
505
- <div
506
- key=${cellKey}
507
- class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? "is-today" : ""}`}
508
- >
509
- ${visibleSlots.map((slot) => {
510
- const status = statusBySlotKey[slot.key] || "";
511
- const isPast = slot.scheduledAtMs <= nowMs;
512
- const tokenTier = getSlotTokenTier(slot);
513
- const tooltipText = buildJobTooltipText({
514
- jobName: slot.jobName,
515
- job: jobById[slot.jobId] || null,
516
- runSummary7d: runSummary7dByJobId[slot.jobId] || {},
517
- slotRun: runBySlotKey[slot.key] || null,
518
- latestRun: latestRunByJobId[slot.jobId],
519
- scheduledAtMs: slot.scheduledAtMs,
520
- scheduledStatus: status,
521
- nowMs,
522
- });
523
- return html`
579
+ ${hourRows.map(
580
+ (hourOfDay) => html`
581
+ <div key=${hourOfDay} class="cron-calendar-grid-row">
582
+ <div class="cron-calendar-hour-cell">
583
+ ${formatHourLabel(hourOfDay)}
584
+ </div>
585
+ ${timeline.days.map((day) => {
586
+ const cellKey = buildCellKey(day.dayKey, hourOfDay);
587
+ const cellSlots = slotsByCellKey[cellKey] || [];
588
+ const visibleSlots = cellSlots.slice(0, 3);
589
+ const overflowCount = Math.max(
590
+ 0,
591
+ cellSlots.length - visibleSlots.length,
592
+ );
593
+ return html`
594
+ <div
595
+ key=${cellKey}
596
+ class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? "is-today" : ""}`}
597
+ >
598
+ ${visibleSlots.map((slot) => {
599
+ const status = statusBySlotKey[slot.key] || "";
600
+ const isPast = slot.scheduledAtMs <= nowMs;
601
+ const tokenTier = getSlotTokenTier(slot);
602
+ const tooltipText = buildJobTooltipText({
603
+ jobName: slot.jobName,
604
+ job: jobById[slot.jobId] || null,
605
+ runSummary7d:
606
+ runSummary7dByJobId[slot.jobId] || {},
607
+ slotRun: runBySlotKey[slot.key] || null,
608
+ latestRun: latestRunByJobId[slot.jobId],
609
+ scheduledAtMs: slot.scheduledAtMs,
610
+ scheduledStatus: status,
611
+ nowMs,
612
+ });
613
+ return html`
524
614
  <${Tooltip}
525
615
  text=${tooltipText}
526
616
  widthClass="w-72"
@@ -529,16 +619,22 @@ export const CronCalendar = ({
529
619
  >
530
620
  <div
531
621
  key=${slot.key}
532
- class=${`cron-calendar-slot-chip ${slotStateClassName({
533
- isPast,
534
- mappedStatus: status,
535
- tokenTier,
536
- })}`}
622
+ class=${`cron-calendar-slot-chip ${slotStateClassName(
623
+ {
624
+ isPast,
625
+ mappedStatus: status,
626
+ tokenTier,
627
+ },
628
+ )}`}
537
629
  role="button"
538
630
  tabindex="0"
539
631
  onClick=${() => onSelectJob(slot.jobId)}
540
632
  onKeyDown=${(event) => {
541
- if (event.key !== "Enter" && event.key !== " ") return;
633
+ if (
634
+ event.key !== "Enter" &&
635
+ event.key !== " "
636
+ )
637
+ return;
542
638
  event.preventDefault();
543
639
  onSelectJob(slot.jobId);
544
640
  }}
@@ -547,19 +643,21 @@ export const CronCalendar = ({
547
643
  </div>
548
644
  </${Tooltip}>
549
645
  `;
550
- })}
551
- ${overflowCount > 0
552
- ? html`<div class="cron-calendar-slot-overflow">+${overflowCount} more</div>`
553
- : null}
554
- </div>
555
- `;
556
- })}
557
- </div>
558
- `)}
646
+ })}
647
+ ${overflowCount > 0
648
+ ? html`<div class="cron-calendar-slot-overflow">
649
+ +${overflowCount} more
650
+ </div>`
651
+ : null}
652
+ </div>
653
+ `;
654
+ })}
655
+ </div>
656
+ `,
657
+ )}
559
658
  </div>
560
659
  </div>
561
660
  `}
562
-
563
661
  ${repeatingJobs.length > 0
564
662
  ? html`
565
663
  <div class="cron-calendar-repeating-strip">
@@ -586,16 +684,19 @@ export const CronCalendar = ({
586
684
  triggerClassName="inline-flex max-w-full"
587
685
  >
588
686
  <div
589
- class=${`cron-calendar-repeating-pill ${slotStateClassName({
590
- isPast: false,
591
- mappedStatus: "",
592
- tokenTier: getJobProjectedTier(jobId),
593
- })}`}
687
+ class=${`cron-calendar-repeating-pill ${slotStateClassName(
688
+ {
689
+ isPast: false,
690
+ mappedStatus: "",
691
+ tokenTier: getJobProjectedTier(jobId),
692
+ },
693
+ )}`}
594
694
  role="button"
595
695
  tabindex="0"
596
696
  onClick=${() => onSelectJob(jobId)}
597
697
  onKeyDown=${(event) => {
598
- if (event.key !== "Enter" && event.key !== " ") return;
698
+ if (event.key !== "Enter" && event.key !== " ")
699
+ return;
599
700
  event.preventDefault();
600
701
  onSelectJob(jobId);
601
702
  }}
@@ -605,9 +706,11 @@ export const CronCalendar = ({
605
706
  ${formatCronScheduleLabel(job.schedule, {
606
707
  includeTimeZoneWhenDifferent: true,
607
708
  })}
608
- ${avgTokensPerRun > 0
609
- ? ` | avg ${formatTokenCount(avgTokensPerRun)} tk`
610
- : ""}
709
+ ${
710
+ avgTokensPerRun > 0
711
+ ? ` | avg ${formatTokenCount(avgTokensPerRun)} tk`
712
+ : ""
713
+ }
611
714
  </span>
612
715
  </div>
613
716
  </${Tooltip}>
@@ -622,24 +725,15 @@ export const CronCalendar = ({
622
725
 
623
726
  return html`
624
727
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
625
- <div class="flex items-center justify-between gap-2">
626
- <div class="flex items-center gap-2">
627
- <h3 class="card-label cron-calendar-title">
628
- ${expanded ? "Rolling 7-Day Schedule" : "Upcoming (next 24h)"}
629
- </h3>
630
- ${!expanded && repeatingJobs.length > 0
631
- ? html`<span class="text-[11px] text-gray-500">+ ${repeatingJobs.length} repeating</span>`
632
- : null}
633
- </div>
634
- <${ActionButton}
635
- onClick=${toggleExpanded}
636
- tone="neutral"
637
- size="sm"
638
- idleLabel=${expanded ? "Collapse" : "Show full week"}
728
+ <div class="flex items-center gap-2">
729
+ <${SegmentedControl}
730
+ options=${kCalendarViewOptions}
731
+ value=${calendarView}
732
+ onChange=${onChangeCalendarView}
639
733
  />
640
734
  </div>
641
735
 
642
- ${expanded ? renderFullGrid() : renderCompactStrip()}
736
+ ${isCalendarView ? renderFullGrid() : renderCompactStrip()}
643
737
  </section>
644
738
  `;
645
739
  };