@stigmer/react 3.5.3 → 3.7.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 (283) hide show
  1. package/agent/AgentPicker.d.ts +10 -1
  2. package/agent/AgentPicker.d.ts.map +1 -1
  3. package/agent/AgentPicker.js +2 -2
  4. package/agent/AgentPicker.js.map +1 -1
  5. package/billing/CreditLedgerTable.d.ts.map +1 -1
  6. package/billing/CreditLedgerTable.js +2 -7
  7. package/billing/CreditLedgerTable.js.map +1 -1
  8. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  9. package/channel/AgentChannelsPanel.js +22 -5
  10. package/channel/AgentChannelsPanel.js.map +1 -1
  11. package/channel/ChannelTemplatesDialog.d.ts +53 -0
  12. package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
  13. package/channel/ChannelTemplatesDialog.js +150 -0
  14. package/channel/ChannelTemplatesDialog.js.map +1 -0
  15. package/channel/index.d.ts +8 -0
  16. package/channel/index.d.ts.map +1 -1
  17. package/channel/index.js +4 -0
  18. package/channel/index.js.map +1 -1
  19. package/channel/providers.d.ts +12 -0
  20. package/channel/providers.d.ts.map +1 -1
  21. package/channel/providers.js +14 -2
  22. package/channel/providers.js.map +1 -1
  23. package/channel/templatePresentation.d.ts +37 -0
  24. package/channel/templatePresentation.d.ts.map +1 -0
  25. package/channel/templatePresentation.js +69 -0
  26. package/channel/templatePresentation.js.map +1 -0
  27. package/channel/useChannelTemplateList.d.ts +64 -0
  28. package/channel/useChannelTemplateList.d.ts.map +1 -0
  29. package/channel/useChannelTemplateList.js +69 -0
  30. package/channel/useChannelTemplateList.js.map +1 -0
  31. package/channel/useChannelTemplateReadiness.d.ts +53 -0
  32. package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
  33. package/channel/useChannelTemplateReadiness.js +42 -0
  34. package/channel/useChannelTemplateReadiness.js.map +1 -0
  35. package/composer/ComposerToolbar.d.ts +6 -1
  36. package/composer/ComposerToolbar.d.ts.map +1 -1
  37. package/composer/ComposerToolbar.js +2 -2
  38. package/composer/ComposerToolbar.js.map +1 -1
  39. package/composer/SessionComposer.d.ts +9 -0
  40. package/composer/SessionComposer.d.ts.map +1 -1
  41. package/composer/SessionComposer.js +13 -2
  42. package/composer/SessionComposer.js.map +1 -1
  43. package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
  44. package/dashboard/useDashboardFailedRuns.js +2 -0
  45. package/dashboard/useDashboardFailedRuns.js.map +1 -1
  46. package/execution/useCreateAgentExecution.d.ts +14 -0
  47. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  48. package/execution/useCreateAgentExecution.js +5 -0
  49. package/execution/useCreateAgentExecution.js.map +1 -1
  50. package/index.d.ts +4 -2
  51. package/index.d.ts.map +1 -1
  52. package/index.js +10 -1
  53. package/index.js.map +1 -1
  54. package/internal/Pagination.d.ts +15 -0
  55. package/internal/Pagination.d.ts.map +1 -0
  56. package/internal/Pagination.js +20 -0
  57. package/internal/Pagination.js.map +1 -0
  58. package/library/useExportResource.d.ts +4 -3
  59. package/library/useExportResource.d.ts.map +1 -1
  60. package/library/useExportResource.js +1 -1
  61. package/library/useExportResource.js.map +1 -1
  62. package/models/ModelSelector.d.ts +26 -1
  63. package/models/ModelSelector.d.ts.map +1 -1
  64. package/models/ModelSelector.js +27 -6
  65. package/models/ModelSelector.js.map +1 -1
  66. package/models/index.d.ts +2 -0
  67. package/models/index.d.ts.map +1 -1
  68. package/models/index.js +1 -0
  69. package/models/index.js.map +1 -1
  70. package/models/registry.d.ts +10 -0
  71. package/models/registry.d.ts.map +1 -1
  72. package/models/registry.js +3 -0
  73. package/models/registry.js.map +1 -1
  74. package/models/service-tier.d.ts +27 -0
  75. package/models/service-tier.d.ts.map +1 -0
  76. package/models/service-tier.js +34 -0
  77. package/models/service-tier.js.map +1 -0
  78. package/organization/OrgProvider.d.ts +11 -4
  79. package/organization/OrgProvider.d.ts.map +1 -1
  80. package/organization/OrgProvider.js +28 -4
  81. package/organization/OrgProvider.js.map +1 -1
  82. package/package.json +4 -4
  83. package/resource-detail/useDeleteResource.d.ts +1 -1
  84. package/resource-detail/useDeleteResource.d.ts.map +1 -1
  85. package/resource-detail/useDeleteResource.js +5 -0
  86. package/resource-detail/useDeleteResource.js.map +1 -1
  87. package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
  88. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  89. package/resource-workbench/components/ResourceWorkbench.js +6 -2
  90. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  91. package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
  92. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  93. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  94. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  95. package/schedule/CadenceField.d.ts +31 -0
  96. package/schedule/CadenceField.d.ts.map +1 -0
  97. package/schedule/CadenceField.js +116 -0
  98. package/schedule/CadenceField.js.map +1 -0
  99. package/schedule/ScheduleDetailView.d.ts +81 -0
  100. package/schedule/ScheduleDetailView.d.ts.map +1 -0
  101. package/schedule/ScheduleDetailView.js +648 -0
  102. package/schedule/ScheduleDetailView.js.map +1 -0
  103. package/schedule/ScheduleForm.d.ts +46 -0
  104. package/schedule/ScheduleForm.d.ts.map +1 -0
  105. package/schedule/ScheduleForm.js +214 -0
  106. package/schedule/ScheduleForm.js.map +1 -0
  107. package/schedule/ScheduleRowActions.d.ts +20 -0
  108. package/schedule/ScheduleRowActions.d.ts.map +1 -0
  109. package/schedule/ScheduleRowActions.js +54 -0
  110. package/schedule/ScheduleRowActions.js.map +1 -0
  111. package/schedule/ScheduleRunsTable.d.ts +72 -0
  112. package/schedule/ScheduleRunsTable.d.ts.map +1 -0
  113. package/schedule/ScheduleRunsTable.js +161 -0
  114. package/schedule/ScheduleRunsTable.js.map +1 -0
  115. package/schedule/TimeZoneField.d.ts +32 -0
  116. package/schedule/TimeZoneField.d.ts.map +1 -0
  117. package/schedule/TimeZoneField.js +104 -0
  118. package/schedule/TimeZoneField.js.map +1 -0
  119. package/schedule/cadence.d.ts +95 -0
  120. package/schedule/cadence.d.ts.map +1 -0
  121. package/schedule/cadence.js +242 -0
  122. package/schedule/cadence.js.map +1 -0
  123. package/schedule/index.d.ts +37 -0
  124. package/schedule/index.d.ts.map +1 -0
  125. package/schedule/index.js +19 -0
  126. package/schedule/index.js.map +1 -0
  127. package/schedule/scheduleColumns.d.ts +26 -0
  128. package/schedule/scheduleColumns.d.ts.map +1 -0
  129. package/schedule/scheduleColumns.js +77 -0
  130. package/schedule/scheduleColumns.js.map +1 -0
  131. package/schedule/scheduleListFn.d.ts +50 -0
  132. package/schedule/scheduleListFn.d.ts.map +1 -0
  133. package/schedule/scheduleListFn.js +65 -0
  134. package/schedule/scheduleListFn.js.map +1 -0
  135. package/schedule/scheduleState.d.ts +43 -0
  136. package/schedule/scheduleState.d.ts.map +1 -0
  137. package/schedule/scheduleState.js +67 -0
  138. package/schedule/scheduleState.js.map +1 -0
  139. package/schedule/useCreateSchedule.d.ts +57 -0
  140. package/schedule/useCreateSchedule.d.ts.map +1 -0
  141. package/schedule/useCreateSchedule.js +61 -0
  142. package/schedule/useCreateSchedule.js.map +1 -0
  143. package/schedule/useResumeSchedule.d.ts +30 -0
  144. package/schedule/useResumeSchedule.d.ts.map +1 -0
  145. package/schedule/useResumeSchedule.js +45 -0
  146. package/schedule/useResumeSchedule.js.map +1 -0
  147. package/schedule/useSchedule.d.ts +39 -0
  148. package/schedule/useSchedule.d.ts.map +1 -0
  149. package/schedule/useSchedule.js +44 -0
  150. package/schedule/useSchedule.js.map +1 -0
  151. package/schedule/useScheduleCount.d.ts +38 -0
  152. package/schedule/useScheduleCount.d.ts.map +1 -0
  153. package/schedule/useScheduleCount.js +30 -0
  154. package/schedule/useScheduleCount.js.map +1 -0
  155. package/schedule/useScheduleList.d.ts +48 -0
  156. package/schedule/useScheduleList.d.ts.map +1 -0
  157. package/schedule/useScheduleList.js +43 -0
  158. package/schedule/useScheduleList.js.map +1 -0
  159. package/schedule/useScheduleRuns.d.ts +44 -0
  160. package/schedule/useScheduleRuns.d.ts.map +1 -0
  161. package/schedule/useScheduleRuns.js +48 -0
  162. package/schedule/useScheduleRuns.js.map +1 -0
  163. package/schedule/useSetScheduleEnabled.d.ts +31 -0
  164. package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
  165. package/schedule/useSetScheduleEnabled.js +53 -0
  166. package/schedule/useSetScheduleEnabled.js.map +1 -0
  167. package/schedule/useTriggerSchedule.d.ts +41 -0
  168. package/schedule/useTriggerSchedule.d.ts.map +1 -0
  169. package/schedule/useTriggerSchedule.js +62 -0
  170. package/schedule/useTriggerSchedule.js.map +1 -0
  171. package/schedule/useUpdateScheduleSpec.d.ts +49 -0
  172. package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
  173. package/schedule/useUpdateScheduleSpec.js +70 -0
  174. package/schedule/useUpdateScheduleSpec.js.map +1 -0
  175. package/search/useResourceCount.d.ts +2 -1
  176. package/search/useResourceCount.d.ts.map +1 -1
  177. package/search/useResourceCount.js +6 -1
  178. package/search/useResourceCount.js.map +1 -1
  179. package/session/useNewSessionFlow.d.ts.map +1 -1
  180. package/session/useNewSessionFlow.js +1 -0
  181. package/session/useNewSessionFlow.js.map +1 -1
  182. package/session/useSessionConversation.d.ts +11 -0
  183. package/session/useSessionConversation.d.ts.map +1 -1
  184. package/session/useSessionConversation.js +1 -0
  185. package/session/useSessionConversation.js.map +1 -1
  186. package/session/useSessionPageFlow.d.ts.map +1 -1
  187. package/session/useSessionPageFlow.js +1 -0
  188. package/session/useSessionPageFlow.js.map +1 -1
  189. package/src/agent/AgentPicker.tsx +13 -1
  190. package/src/billing/CreditLedgerTable.tsx +3 -42
  191. package/src/channel/AgentChannelsPanel.tsx +43 -1
  192. package/src/channel/ChannelTemplatesDialog.tsx +433 -0
  193. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
  194. package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
  195. package/src/channel/__tests__/providers.test.ts +9 -0
  196. package/src/channel/__tests__/templatePresentation.test.ts +96 -0
  197. package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
  198. package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
  199. package/src/channel/index.ts +15 -0
  200. package/src/channel/providers.ts +26 -2
  201. package/src/channel/templatePresentation.ts +82 -0
  202. package/src/channel/useChannelTemplateList.ts +123 -0
  203. package/src/channel/useChannelTemplateReadiness.ts +69 -0
  204. package/src/composer/ComposerToolbar.tsx +9 -0
  205. package/src/composer/SessionComposer.tsx +24 -1
  206. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +156 -0
  207. package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
  208. package/src/dashboard/useDashboardFailedRuns.ts +2 -0
  209. package/src/execution/useCreateAgentExecution.ts +18 -0
  210. package/src/index.ts +70 -0
  211. package/src/internal/Pagination.tsx +72 -0
  212. package/src/library/useExportResource.ts +4 -3
  213. package/src/models/ModelSelector.tsx +100 -3
  214. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +172 -0
  215. package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
  216. package/src/models/index.ts +2 -0
  217. package/src/models/registry.ts +16 -0
  218. package/src/models/service-tier.ts +49 -0
  219. package/src/organization/OrgProvider.tsx +29 -4
  220. package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
  221. package/src/resource-detail/useDeleteResource.ts +11 -1
  222. package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
  223. package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
  224. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  225. package/src/schedule/CadenceField.tsx +365 -0
  226. package/src/schedule/ScheduleDetailView.tsx +1651 -0
  227. package/src/schedule/ScheduleForm.tsx +615 -0
  228. package/src/schedule/ScheduleRowActions.tsx +124 -0
  229. package/src/schedule/ScheduleRunsTable.tsx +424 -0
  230. package/src/schedule/TimeZoneField.tsx +199 -0
  231. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +683 -0
  232. package/src/schedule/__tests__/cadence.test.ts +278 -0
  233. package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
  234. package/src/schedule/__tests__/scheduleCreation.test.tsx +537 -0
  235. package/src/schedule/__tests__/scheduleHooks.test.tsx +399 -0
  236. package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
  237. package/src/schedule/__tests__/scheduleState.test.ts +133 -0
  238. package/src/schedule/cadence.ts +298 -0
  239. package/src/schedule/index.ts +69 -0
  240. package/src/schedule/scheduleColumns.tsx +118 -0
  241. package/src/schedule/scheduleListFn.ts +108 -0
  242. package/src/schedule/scheduleState.ts +96 -0
  243. package/src/schedule/useCreateSchedule.ts +88 -0
  244. package/src/schedule/useResumeSchedule.ts +67 -0
  245. package/src/schedule/useSchedule.ts +67 -0
  246. package/src/schedule/useScheduleCount.ts +57 -0
  247. package/src/schedule/useScheduleList.ts +83 -0
  248. package/src/schedule/useScheduleRuns.ts +93 -0
  249. package/src/schedule/useSetScheduleEnabled.ts +84 -0
  250. package/src/schedule/useTriggerSchedule.ts +90 -0
  251. package/src/schedule/useUpdateScheduleSpec.ts +108 -0
  252. package/src/search/__tests__/useResourceCount.test.tsx +89 -0
  253. package/src/search/useResourceCount.ts +8 -2
  254. package/src/session/useNewSessionFlow.ts +1 -0
  255. package/src/session/useSessionConversation.ts +12 -0
  256. package/src/session/useSessionPageFlow.ts +1 -0
  257. package/src/workflow/WorkflowDashboard.tsx +1 -0
  258. package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
  259. package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
  260. package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
  261. package/src/workflow/starter-workflow-yaml.ts +2 -1
  262. package/src/workflow/useWorkflowExecutionList.ts +13 -2
  263. package/styles.css +1 -1
  264. package/workflow/CanvasTransitionEdge.d.ts +1 -1
  265. package/workflow/WorkflowDashboard.d.ts.map +1 -1
  266. package/workflow/WorkflowDashboard.js +1 -0
  267. package/workflow/WorkflowDashboard.js.map +1 -1
  268. package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
  269. package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
  270. package/workflow/inspector/forms/AgentCallForm.js +87 -9
  271. package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
  272. package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
  273. package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
  274. package/workflow/inspector/tabs/RuntimeTab.js +31 -5
  275. package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
  276. package/workflow/starter-workflow-yaml.d.ts +1 -1
  277. package/workflow/starter-workflow-yaml.d.ts.map +1 -1
  278. package/workflow/starter-workflow-yaml.js +2 -1
  279. package/workflow/starter-workflow-yaml.js.map +1 -1
  280. package/workflow/useWorkflowExecutionList.d.ts +10 -0
  281. package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
  282. package/workflow/useWorkflowExecutionList.js +3 -2
  283. package/workflow/useWorkflowExecutionList.js.map +1 -1
@@ -57,6 +57,14 @@ export interface UseResourceCollectionOptions<TData = SearchResult> {
57
57
  readonly columns?: readonly WorkbenchColumnDef<TData>[];
58
58
  /** Enable row selection state tracking. @default false */
59
59
  readonly enableSelection?: boolean;
60
+ /**
61
+ * Extracts a stable unique ID from an item, used as the TanStack row
62
+ * id. Provide it whenever the row type does not carry a top-level
63
+ * `id` (e.g. full resource protos, where the id lives at
64
+ * `metadata.id`). When omitted, falls back to `SearchResult.id`, then
65
+ * the row index.
66
+ */
67
+ readonly getItemId?: (item: TData) => string;
60
68
  /**
61
69
  * Opaque token that forces a background refetch whenever its value
62
70
  * changes. Use it to re-read the list after an out-of-band mutation
@@ -157,6 +165,7 @@ export function useResourceCollection<TData = SearchResult>(
157
165
  onSortChange,
158
166
  columns = [],
159
167
  enableSelection = false,
168
+ getItemId,
160
169
  refetchToken,
161
170
  } = options;
162
171
 
@@ -247,7 +256,8 @@ export function useResourceCollection<TData = SearchResult>(
247
256
  onRowSelectionChange: enableSelection ? setRowSelection : undefined,
248
257
  getCoreRowModel: getCoreRowModel(),
249
258
  getSortedRowModel: getSortedRowModel(),
250
- getRowId: (row) => (row as SearchResult).id ?? String(items.indexOf(row as TData)),
259
+ getRowId: (row, index) =>
260
+ getItemId?.(row) || (row as SearchResult).id || String(index),
251
261
  manualPagination: true,
252
262
  manualSorting: true,
253
263
  enableRowSelection: enableSelection,
@@ -0,0 +1,365 @@
1
+ "use client";
2
+
3
+ import { useCallback, useId } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import {
6
+ cadenceToCron,
7
+ describeCadence,
8
+ formatTime,
9
+ validateCron,
10
+ WEEKDAY_LABELS,
11
+ type CadenceKind,
12
+ type CadencePreset,
13
+ } from "./cadence.js";
14
+
15
+ /** Props for {@link CadenceField}. */
16
+ export interface CadenceFieldProps {
17
+ /** The current cadence. */
18
+ readonly value: CadencePreset;
19
+ /** Called with the new cadence on every edit. */
20
+ readonly onChange: (preset: CadencePreset) => void;
21
+ /** Time zone name, echoed into the summary line. */
22
+ readonly timeZone?: string;
23
+ /** Prevents interaction when `true`. */
24
+ readonly disabled?: boolean;
25
+ }
26
+
27
+ const KIND_OPTIONS: readonly { kind: CadenceKind; label: string }[] = [
28
+ { kind: "hourly", label: "Hourly" },
29
+ { kind: "daily", label: "Daily" },
30
+ { kind: "weekly", label: "Weekly" },
31
+ { kind: "monthly", label: "Monthly" },
32
+ { kind: "custom", label: "Custom cron" },
33
+ ];
34
+
35
+ /**
36
+ * Human-friendly cadence builder for schedules.
37
+ *
38
+ * Presents preset cadences (hourly / daily / weekly / monthly) with
39
+ * plain time-and-day inputs, generating the 5-field cron expression by
40
+ * construction — the user never writes cron unless they choose the
41
+ * Custom escape hatch, where the raw expression is validated instantly
42
+ * with the platform's own lexical rules ({@link validateCron}).
43
+ *
44
+ * A live plain-English summary ({@link describeCadence}) always states
45
+ * what was chosen. It describes the preset, not a computed fire-time
46
+ * forecast — the authoritative next fire time is published by the
47
+ * server as `status.next_fire_at` after apply.
48
+ *
49
+ * Controlled component over {@link CadencePreset}; convert to the spec
50
+ * string with {@link cadenceToCron} at submit time.
51
+ */
52
+ export function CadenceField({
53
+ value,
54
+ onChange,
55
+ timeZone,
56
+ disabled,
57
+ }: CadenceFieldProps) {
58
+ const groupId = useId();
59
+
60
+ const switchKind = useCallback(
61
+ (kind: CadenceKind) => {
62
+ if (kind === value.kind) return;
63
+ onChange(presetForKind(kind, value));
64
+ },
65
+ [value, onChange],
66
+ );
67
+
68
+ const cronError =
69
+ value.kind === "custom" && value.cron.trim() !== ""
70
+ ? validateCron(value.cron.trim())
71
+ : null;
72
+
73
+ return (
74
+ <div className="space-y-2">
75
+ {/* Preset selector — segmented radio group, matching ScopeToggle's pattern */}
76
+ <div
77
+ role="radiogroup"
78
+ aria-label="Cadence"
79
+ aria-disabled={disabled || undefined}
80
+ className={cn(
81
+ "inline-flex flex-wrap rounded-md bg-muted p-0.5",
82
+ disabled && "pointer-events-none opacity-50",
83
+ )}
84
+ >
85
+ {KIND_OPTIONS.map((option) => {
86
+ const isSelected = value.kind === option.kind;
87
+ return (
88
+ <button
89
+ key={option.kind}
90
+ type="button"
91
+ role="radio"
92
+ aria-checked={isSelected}
93
+ tabIndex={isSelected ? 0 : -1}
94
+ disabled={disabled}
95
+ onClick={() => switchKind(option.kind)}
96
+ onKeyDown={(e) => {
97
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
98
+ e.preventDefault();
99
+ const idx = KIND_OPTIONS.findIndex((o) => o.kind === value.kind);
100
+ const delta = e.key === "ArrowRight" ? 1 : -1;
101
+ const next =
102
+ KIND_OPTIONS[
103
+ (idx + delta + KIND_OPTIONS.length) % KIND_OPTIONS.length
104
+ ];
105
+ switchKind(next.kind);
106
+ }
107
+ }}
108
+ className={cn(
109
+ "inline-flex cursor-pointer items-center rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
110
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
111
+ isSelected
112
+ ? "bg-background text-foreground shadow-sm"
113
+ : "text-muted-foreground hover:text-foreground",
114
+ )}
115
+ >
116
+ {option.label}
117
+ </button>
118
+ );
119
+ })}
120
+ </div>
121
+
122
+ {/* Per-preset inputs */}
123
+ {value.kind === "hourly" && (
124
+ <div className="flex items-center gap-2 text-xs text-foreground">
125
+ <label htmlFor={`${groupId}-minute`}>At minute</label>
126
+ <input
127
+ id={`${groupId}-minute`}
128
+ type="number"
129
+ min={0}
130
+ max={59}
131
+ value={value.minute}
132
+ onChange={(e) =>
133
+ onChange({
134
+ kind: "hourly",
135
+ minute: clampInt(e.target.value, 0, 59, value.minute),
136
+ })
137
+ }
138
+ disabled={disabled}
139
+ className={numberInputClasses}
140
+ />
141
+ </div>
142
+ )}
143
+
144
+ {value.kind === "daily" && (
145
+ <TimeInput
146
+ id={`${groupId}-time`}
147
+ hour={value.hour}
148
+ minute={value.minute}
149
+ disabled={disabled}
150
+ onChange={(hour, minute) => onChange({ kind: "daily", hour, minute })}
151
+ />
152
+ )}
153
+
154
+ {value.kind === "weekly" && (
155
+ <div className="space-y-2">
156
+ <div
157
+ role="group"
158
+ aria-label="Days of week"
159
+ className="flex flex-wrap gap-1"
160
+ >
161
+ {WEEKDAY_LABELS.map((label, day) => {
162
+ const isOn = value.days.includes(day);
163
+ // The last selected day cannot be removed — a weekly
164
+ // schedule with zero days is not expressible in cron.
165
+ const isLastSelected = isOn && value.days.length === 1;
166
+ return (
167
+ <button
168
+ key={label}
169
+ type="button"
170
+ aria-pressed={isOn}
171
+ aria-label={label}
172
+ title={label}
173
+ disabled={disabled || isLastSelected}
174
+ onClick={() =>
175
+ onChange({
176
+ ...value,
177
+ days: isOn
178
+ ? value.days.filter((d) => d !== day)
179
+ : [...value.days, day].sort((a, b) => a - b),
180
+ })
181
+ }
182
+ className={cn(
183
+ "inline-flex h-7 w-9 items-center justify-center rounded-md border text-xs font-medium transition-colors",
184
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
185
+ "disabled:pointer-events-none",
186
+ isOn
187
+ ? "border-primary bg-primary text-primary-foreground"
188
+ : "border-input bg-background text-muted-foreground hover:text-foreground",
189
+ isLastSelected && "opacity-70",
190
+ )}
191
+ >
192
+ {label.slice(0, 3)}
193
+ </button>
194
+ );
195
+ })}
196
+ </div>
197
+ <TimeInput
198
+ id={`${groupId}-time`}
199
+ hour={value.hour}
200
+ minute={value.minute}
201
+ disabled={disabled}
202
+ onChange={(hour, minute) => onChange({ ...value, hour, minute })}
203
+ />
204
+ </div>
205
+ )}
206
+
207
+ {value.kind === "monthly" && (
208
+ <div className="space-y-2">
209
+ <div className="flex items-center gap-2 text-xs text-foreground">
210
+ <label htmlFor={`${groupId}-day`}>On day</label>
211
+ <input
212
+ id={`${groupId}-day`}
213
+ type="number"
214
+ min={1}
215
+ max={31}
216
+ value={value.day}
217
+ onChange={(e) =>
218
+ onChange({
219
+ ...value,
220
+ day: clampInt(e.target.value, 1, 31, value.day),
221
+ })
222
+ }
223
+ disabled={disabled}
224
+ className={numberInputClasses}
225
+ />
226
+ <span className="text-muted-foreground">of every month</span>
227
+ </div>
228
+ {value.day >= 29 && (
229
+ <p className="text-[0.65rem] text-muted-foreground">
230
+ Months without day {value.day} are skipped — February never
231
+ fires a day-{value.day} schedule
232
+ {value.day === 29 ? " except in leap years" : ""}.
233
+ </p>
234
+ )}
235
+ <TimeInput
236
+ id={`${groupId}-time`}
237
+ hour={value.hour}
238
+ minute={value.minute}
239
+ disabled={disabled}
240
+ onChange={(hour, minute) => onChange({ ...value, hour, minute })}
241
+ />
242
+ </div>
243
+ )}
244
+
245
+ {value.kind === "custom" && (
246
+ <div className="space-y-1">
247
+ <input
248
+ type="text"
249
+ aria-label="Cron expression"
250
+ placeholder="e.g. 0 9 * * MON-FRI"
251
+ value={value.cron}
252
+ onChange={(e) => onChange({ kind: "custom", cron: e.target.value })}
253
+ disabled={disabled}
254
+ className={cn(
255
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 font-mono text-xs text-foreground",
256
+ "placeholder:font-sans placeholder:text-muted-foreground",
257
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
258
+ "disabled:pointer-events-none disabled:opacity-50",
259
+ cronError && "border-destructive",
260
+ )}
261
+ />
262
+ {cronError ? (
263
+ <p className="text-[0.65rem] text-destructive" role="alert">
264
+ {cronError}
265
+ </p>
266
+ ) : (
267
+ <p className="text-[0.65rem] text-muted-foreground">
268
+ 5 fields (minute hour day-of-month month day-of-week) or
269
+ @hourly, @daily, @weekly, @monthly, @yearly.
270
+ </p>
271
+ )}
272
+ </div>
273
+ )}
274
+
275
+ {/* Plain-English summary of the chosen cadence */}
276
+ {!(value.kind === "custom" && (cronError || value.cron.trim() === "")) && (
277
+ <p className="text-xs text-muted-foreground" data-testid="cadence-summary">
278
+ {describeCadence(value, timeZone)}
279
+ </p>
280
+ )}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Private helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ const numberInputClasses = cn(
290
+ "w-16 rounded-md border border-input bg-background px-2 py-1 text-xs text-foreground",
291
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
292
+ "disabled:pointer-events-none disabled:opacity-50",
293
+ );
294
+
295
+ function TimeInput({
296
+ id,
297
+ hour,
298
+ minute,
299
+ disabled,
300
+ onChange,
301
+ }: {
302
+ readonly id: string;
303
+ readonly hour: number;
304
+ readonly minute: number;
305
+ readonly disabled?: boolean;
306
+ readonly onChange: (hour: number, minute: number) => void;
307
+ }) {
308
+ return (
309
+ <div className="flex items-center gap-2 text-xs text-foreground">
310
+ <label htmlFor={id}>At</label>
311
+ <input
312
+ id={id}
313
+ type="time"
314
+ value={formatTime(hour, minute)}
315
+ onChange={(e) => {
316
+ const [h, m] = e.target.value.split(":").map(Number);
317
+ if (Number.isInteger(h) && Number.isInteger(m)) onChange(h, m);
318
+ }}
319
+ disabled={disabled}
320
+ className={cn(
321
+ "rounded-md border border-input bg-background px-2 py-1 text-xs text-foreground",
322
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
323
+ "disabled:pointer-events-none disabled:opacity-50",
324
+ )}
325
+ />
326
+ </div>
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Next preset when the user switches kind, carrying the time of day
332
+ * across so "Daily at 09:30" → Weekly keeps 09:30. Switching to Custom
333
+ * prefills the generated cron from the outgoing preset — the escape
334
+ * hatch starts from what the user already built, not a blank box.
335
+ */
336
+ function presetForKind(kind: CadenceKind, previous: CadencePreset): CadencePreset {
337
+ const hour = "hour" in previous ? previous.hour : 9;
338
+ const minute = "minute" in previous ? previous.minute : 0;
339
+ switch (kind) {
340
+ case "hourly":
341
+ return { kind, minute };
342
+ case "daily":
343
+ return { kind, hour, minute };
344
+ case "weekly":
345
+ return { kind, days: [1], hour, minute };
346
+ case "monthly":
347
+ return { kind, day: 1, hour, minute };
348
+ case "custom":
349
+ return {
350
+ kind,
351
+ cron: previous.kind === "custom" ? previous.cron : cadenceToCron(previous),
352
+ };
353
+ }
354
+ }
355
+
356
+ function clampInt(
357
+ raw: string,
358
+ min: number,
359
+ max: number,
360
+ fallback: number,
361
+ ): number {
362
+ const n = Number(raw);
363
+ if (!Number.isInteger(n)) return fallback;
364
+ return Math.min(max, Math.max(min, n));
365
+ }