@stigmer/react 3.6.0 → 3.8.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 (260) hide show
  1. package/billing/CreditLedgerTable.d.ts.map +1 -1
  2. package/billing/CreditLedgerTable.js +2 -7
  3. package/billing/CreditLedgerTable.js.map +1 -1
  4. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  5. package/channel/AgentChannelsPanel.js +21 -2
  6. package/channel/AgentChannelsPanel.js.map +1 -1
  7. package/channel/ChannelConversationsDialog.d.ts +12 -4
  8. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  9. package/channel/ChannelConversationsDialog.js +18 -24
  10. package/channel/ChannelConversationsDialog.js.map +1 -1
  11. package/channel/providers.d.ts +23 -0
  12. package/channel/providers.d.ts.map +1 -1
  13. package/channel/providers.js +4 -0
  14. package/channel/providers.js.map +1 -1
  15. package/channel/useSaveAgentChannel.d.ts +5 -4
  16. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  17. package/channel/useSaveAgentChannel.js +18 -4
  18. package/channel/useSaveAgentChannel.js.map +1 -1
  19. package/composer/ComposerToolbar.d.ts +6 -1
  20. package/composer/ComposerToolbar.d.ts.map +1 -1
  21. package/composer/ComposerToolbar.js +2 -2
  22. package/composer/ComposerToolbar.js.map +1 -1
  23. package/composer/SessionComposer.d.ts +9 -0
  24. package/composer/SessionComposer.d.ts.map +1 -1
  25. package/composer/SessionComposer.js +13 -2
  26. package/composer/SessionComposer.js.map +1 -1
  27. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  28. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  29. package/conversation/ConversationAttentionBanner.js +25 -0
  30. package/conversation/ConversationAttentionBanner.js.map +1 -0
  31. package/conversation/ConversationComposer.d.ts +41 -0
  32. package/conversation/ConversationComposer.d.ts.map +1 -0
  33. package/conversation/ConversationComposer.js +57 -0
  34. package/conversation/ConversationComposer.js.map +1 -0
  35. package/conversation/ConversationControlBanner.d.ts +52 -0
  36. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  37. package/conversation/ConversationControlBanner.js +59 -0
  38. package/conversation/ConversationControlBanner.js.map +1 -0
  39. package/conversation/ConversationListPane.d.ts +68 -0
  40. package/conversation/ConversationListPane.d.ts.map +1 -0
  41. package/conversation/ConversationListPane.js +120 -0
  42. package/conversation/ConversationListPane.js.map +1 -0
  43. package/conversation/ConversationTimelineView.d.ts +46 -0
  44. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  45. package/conversation/ConversationTimelineView.js +166 -0
  46. package/conversation/ConversationTimelineView.js.map +1 -0
  47. package/conversation/ConversationsWorkbench.d.ts +63 -0
  48. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  49. package/conversation/ConversationsWorkbench.js +165 -0
  50. package/conversation/ConversationsWorkbench.js.map +1 -0
  51. package/conversation/conversationPresentation.d.ts +137 -0
  52. package/conversation/conversationPresentation.d.ts.map +1 -0
  53. package/conversation/conversationPresentation.js +230 -0
  54. package/conversation/conversationPresentation.js.map +1 -0
  55. package/conversation/index.d.ts +26 -0
  56. package/conversation/index.d.ts.map +1 -0
  57. package/conversation/index.js +14 -0
  58. package/conversation/index.js.map +1 -0
  59. package/conversation/polling.d.ts +39 -0
  60. package/conversation/polling.d.ts.map +1 -0
  61. package/conversation/polling.js +39 -0
  62. package/conversation/polling.js.map +1 -0
  63. package/conversation/useConversation.d.ts +61 -0
  64. package/conversation/useConversation.d.ts.map +1 -0
  65. package/conversation/useConversation.js +153 -0
  66. package/conversation/useConversation.js.map +1 -0
  67. package/conversation/useConversationList.d.ts +98 -0
  68. package/conversation/useConversationList.d.ts.map +1 -0
  69. package/conversation/useConversationList.js +200 -0
  70. package/conversation/useConversationList.js.map +1 -0
  71. package/conversation/useConversationParticipation.d.ts +63 -0
  72. package/conversation/useConversationParticipation.d.ts.map +1 -0
  73. package/conversation/useConversationParticipation.js +84 -0
  74. package/conversation/useConversationParticipation.js.map +1 -0
  75. package/conversation/useConversationTimeline.d.ts +71 -0
  76. package/conversation/useConversationTimeline.d.ts.map +1 -0
  77. package/conversation/useConversationTimeline.js +152 -0
  78. package/conversation/useConversationTimeline.js.map +1 -0
  79. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  80. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  81. package/conversation/useConversationsWantsHumanCount.js +40 -0
  82. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  83. package/execution/useCreateAgentExecution.d.ts +14 -0
  84. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  85. package/execution/useCreateAgentExecution.js +5 -0
  86. package/execution/useCreateAgentExecution.js.map +1 -1
  87. package/index.d.ts +6 -2
  88. package/index.d.ts.map +1 -1
  89. package/index.js +14 -5
  90. package/index.js.map +1 -1
  91. package/internal/Pagination.d.ts +15 -0
  92. package/internal/Pagination.d.ts.map +1 -0
  93. package/internal/Pagination.js +20 -0
  94. package/internal/Pagination.js.map +1 -0
  95. package/internal/tooltip.d.ts.map +1 -1
  96. package/internal/tooltip.js +7 -1
  97. package/internal/tooltip.js.map +1 -1
  98. package/internal/useAutoScroll.d.ts +25 -4
  99. package/internal/useAutoScroll.d.ts.map +1 -1
  100. package/internal/useAutoScroll.js +55 -15
  101. package/internal/useAutoScroll.js.map +1 -1
  102. package/internal/useFetch.d.ts.map +1 -1
  103. package/internal/useFetch.js +14 -4
  104. package/internal/useFetch.js.map +1 -1
  105. package/models/ModelSelector.d.ts +45 -1
  106. package/models/ModelSelector.d.ts.map +1 -1
  107. package/models/ModelSelector.js +49 -6
  108. package/models/ModelSelector.js.map +1 -1
  109. package/models/index.d.ts +2 -0
  110. package/models/index.d.ts.map +1 -1
  111. package/models/index.js +1 -0
  112. package/models/index.js.map +1 -1
  113. package/models/registry.d.ts +11 -0
  114. package/models/registry.d.ts.map +1 -1
  115. package/models/registry.js +3 -0
  116. package/models/registry.js.map +1 -1
  117. package/models/service-tier.d.ts +27 -0
  118. package/models/service-tier.d.ts.map +1 -0
  119. package/models/service-tier.js +34 -0
  120. package/models/service-tier.js.map +1 -0
  121. package/package.json +4 -4
  122. package/schedule/ScheduleDetailView.d.ts +31 -8
  123. package/schedule/ScheduleDetailView.d.ts.map +1 -1
  124. package/schedule/ScheduleDetailView.js +441 -29
  125. package/schedule/ScheduleDetailView.js.map +1 -1
  126. package/schedule/ScheduleForm.d.ts +7 -4
  127. package/schedule/ScheduleForm.d.ts.map +1 -1
  128. package/schedule/ScheduleForm.js +94 -8
  129. package/schedule/ScheduleForm.js.map +1 -1
  130. package/schedule/ScheduleRunsTable.d.ts +72 -0
  131. package/schedule/ScheduleRunsTable.d.ts.map +1 -0
  132. package/schedule/ScheduleRunsTable.js +161 -0
  133. package/schedule/ScheduleRunsTable.js.map +1 -0
  134. package/schedule/index.d.ts +6 -0
  135. package/schedule/index.d.ts.map +1 -1
  136. package/schedule/index.js +3 -0
  137. package/schedule/index.js.map +1 -1
  138. package/schedule/useScheduleRuns.d.ts +44 -0
  139. package/schedule/useScheduleRuns.d.ts.map +1 -0
  140. package/schedule/useScheduleRuns.js +48 -0
  141. package/schedule/useScheduleRuns.js.map +1 -0
  142. package/schedule/useTriggerSchedule.d.ts +23 -10
  143. package/schedule/useTriggerSchedule.d.ts.map +1 -1
  144. package/schedule/useTriggerSchedule.js +28 -9
  145. package/schedule/useTriggerSchedule.js.map +1 -1
  146. package/schedule/useUpdateScheduleSpec.d.ts +49 -0
  147. package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
  148. package/schedule/useUpdateScheduleSpec.js +70 -0
  149. package/schedule/useUpdateScheduleSpec.js.map +1 -0
  150. package/session/useNewSessionFlow.d.ts.map +1 -1
  151. package/session/useNewSessionFlow.js +1 -0
  152. package/session/useNewSessionFlow.js.map +1 -1
  153. package/session/useSessionConversation.d.ts +11 -0
  154. package/session/useSessionConversation.d.ts.map +1 -1
  155. package/session/useSessionConversation.js +1 -0
  156. package/session/useSessionConversation.js.map +1 -1
  157. package/session/useSessionPageFlow.d.ts.map +1 -1
  158. package/session/useSessionPageFlow.js +1 -0
  159. package/session/useSessionPageFlow.js.map +1 -1
  160. package/sharing/useSaveAgentShare.d.ts +10 -1
  161. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  162. package/sharing/useSaveAgentShare.js +9 -0
  163. package/sharing/useSaveAgentShare.js.map +1 -1
  164. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  165. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  166. package/sidebar/WorkspaceSidebar.js +19 -7
  167. package/sidebar/WorkspaceSidebar.js.map +1 -1
  168. package/src/billing/CreditLedgerTable.tsx +3 -42
  169. package/src/channel/AgentChannelsPanel.tsx +33 -2
  170. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  171. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  172. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  173. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  174. package/src/channel/providers.ts +27 -0
  175. package/src/channel/useSaveAgentChannel.ts +18 -4
  176. package/src/composer/ComposerToolbar.tsx +9 -0
  177. package/src/composer/SessionComposer.tsx +24 -1
  178. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +157 -0
  179. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  180. package/src/conversation/ConversationComposer.tsx +156 -0
  181. package/src/conversation/ConversationControlBanner.tsx +193 -0
  182. package/src/conversation/ConversationListPane.tsx +459 -0
  183. package/src/conversation/ConversationTimelineView.tsx +435 -0
  184. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  185. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  186. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  187. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  188. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  189. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  190. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  191. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  192. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  193. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  194. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  195. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  196. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  197. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  198. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  199. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  200. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  201. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  202. package/src/conversation/conversationPresentation.ts +305 -0
  203. package/src/conversation/index.ts +80 -0
  204. package/src/conversation/polling.ts +41 -0
  205. package/src/conversation/useConversation.ts +221 -0
  206. package/src/conversation/useConversationList.ts +328 -0
  207. package/src/conversation/useConversationParticipation.ts +178 -0
  208. package/src/conversation/useConversationTimeline.ts +244 -0
  209. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  210. package/src/execution/useCreateAgentExecution.ts +18 -0
  211. package/src/index.ts +76 -4
  212. package/src/internal/Pagination.tsx +72 -0
  213. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  214. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  215. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  216. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  217. package/src/internal/tooltip.tsx +6 -1
  218. package/src/internal/useAutoScroll.ts +66 -20
  219. package/src/internal/useFetch.ts +15 -4
  220. package/src/models/ModelSelector.tsx +133 -4
  221. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +256 -0
  222. package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
  223. package/src/models/index.ts +2 -0
  224. package/src/models/registry.ts +17 -0
  225. package/src/models/service-tier.ts +49 -0
  226. package/src/schedule/ScheduleDetailView.tsx +1179 -111
  227. package/src/schedule/ScheduleForm.tsx +218 -7
  228. package/src/schedule/ScheduleRunsTable.tsx +424 -0
  229. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +431 -8
  230. package/src/schedule/__tests__/scheduleCreation.test.tsx +197 -0
  231. package/src/schedule/__tests__/scheduleHooks.test.tsx +94 -7
  232. package/src/schedule/index.ts +12 -0
  233. package/src/schedule/useScheduleRuns.ts +93 -0
  234. package/src/schedule/useTriggerSchedule.ts +39 -14
  235. package/src/schedule/useUpdateScheduleSpec.ts +108 -0
  236. package/src/session/useNewSessionFlow.ts +1 -0
  237. package/src/session/useSessionConversation.ts +12 -0
  238. package/src/session/useSessionPageFlow.ts +1 -0
  239. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  240. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  241. package/src/sharing/useSaveAgentShare.ts +19 -1
  242. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  243. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  244. package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
  245. package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
  246. package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
  247. package/src/workflow/starter-workflow-yaml.ts +2 -1
  248. package/styles.css +1 -1
  249. package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
  250. package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
  251. package/workflow/inspector/forms/AgentCallForm.js +87 -9
  252. package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
  253. package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
  254. package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
  255. package/workflow/inspector/tabs/RuntimeTab.js +31 -5
  256. package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
  257. package/workflow/starter-workflow-yaml.d.ts +1 -1
  258. package/workflow/starter-workflow-yaml.d.ts.map +1 -1
  259. package/workflow/starter-workflow-yaml.js +2 -1
  260. package/workflow/starter-workflow-yaml.js.map +1 -1
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Popover } from "@base-ui/react/popover";
5
5
  import { cn } from "@stigmer/theme";
6
6
  import { useStigmerPortalContainer } from "../portal-container.js";
7
+ import { Switch } from "../switch/Switch.js";
7
8
  import { useModelRegistry } from "./useModelRegistry.js";
8
9
  import type { ModelInfo, CostTier, SpeedTier } from "./registry.js";
9
10
  import { HARNESS_META, HARNESS_OPTIONS, type HarnessOption } from "./harness.js";
11
+ import { FAST_SERVICE_TIER, type ServiceTierOption } from "./service-tier.js";
10
12
 
11
13
  const COST_TIER_LABEL: Record<CostTier, string> = {
12
14
  economy: "$",
@@ -61,6 +63,37 @@ export interface ModelSelectorProps {
61
63
  readonly className?: string;
62
64
  /** When true, disables the selector. */
63
65
  readonly disabled?: boolean;
66
+ /**
67
+ * Trigger label shown while `value` is empty, instead of falling back
68
+ * to the registry's default model. Lets a form distinguish "nothing
69
+ * pinned — the platform default applies" from an actual selection
70
+ * (the schedule form's contract: an unset model inherits the
71
+ * surface's platform default). The harness prefix is hidden while
72
+ * the placeholder shows, since no engine is pinned either.
73
+ */
74
+ readonly placeholderLabel?: string;
75
+
76
+ /**
77
+ * Current service tier for the selected model (stigmer/stigmer#357).
78
+ * Defaults to "standard". Only meaningful together with
79
+ * {@link onServiceTierChange}.
80
+ */
81
+ readonly serviceTier?: ServiceTierOption;
82
+ /**
83
+ * Called when the user toggles the fast tier. Providing this enables the
84
+ * "Fast tier" switch in the popover's options area (top of the popover,
85
+ * beside the harness row — the Cursor options-panel convention), which
86
+ * renders ONLY while the selected model prices a fast variant
87
+ * ({@link ModelInfo.serviceTiers}) — no dead controls.
88
+ *
89
+ * An active fast tier persists across switches between fast-capable
90
+ * models (the tier is always visible: trigger badge + options switch),
91
+ * so "fast, but on that other model" is one action, not a re-toggle.
92
+ * Selecting a model without a fast tier resets to "standard" through
93
+ * this callback — an unsupported combination can never be submitted
94
+ * (#357, refused at create time).
95
+ */
96
+ readonly onServiceTierChange?: (tier: ServiceTierOption) => void;
64
97
 
65
98
  /**
66
99
  * @deprecated Use {@link onHarnessChange} instead.
@@ -79,6 +112,19 @@ export interface ModelSelectorProps {
79
112
  * The trigger button displays the current selection in compact format:
80
113
  * `Harness · Model Name ▾` (or just `Model Name ▾` when harness is locked).
81
114
  *
115
+ * **Service tier (#357).** When the consumer opts in via
116
+ * {@link ModelSelectorProps.onServiceTierChange} and the selected model
117
+ * prices a fast variant, a "Fast tier" switch renders in the options area
118
+ * at the TOP of the popover — the first thing visible on open, mirroring
119
+ * Cursor's own options panel (Jakob's Law: our users already know that
120
+ * layout). Deliberately not a per-row affordance: row-level chips crowd
121
+ * the rows and collide with the "Fast/Fastest" speed badge, and an
122
+ * interactive element inside `role="option"` is an ARIA authoring error
123
+ * (option children are presentational). An active fast tier persists
124
+ * across switches between fast-capable models — the visible badge and
125
+ * switch keep the state honest — and resets only for models with no fast
126
+ * variant, where it would be refused at create time.
127
+ *
82
128
  * @example
83
129
  * ```tsx
84
130
  * <ModelSelector
@@ -104,6 +150,9 @@ export function ModelSelector({
104
150
  compact = false,
105
151
  className,
106
152
  disabled,
153
+ placeholderLabel,
154
+ serviceTier = "standard",
155
+ onServiceTierChange,
107
156
  }: ModelSelectorProps) {
108
157
  const portalContainer = useStigmerPortalContainer();
109
158
 
@@ -216,9 +265,21 @@ export function ModelSelector({
216
265
  const selectModel = useCallback(
217
266
  (model: ModelInfo) => {
218
267
  onValueChange(model.modelId);
268
+ // An active fast tier survives a switch between fast-capable models —
269
+ // resetting it forced users to re-toggle on every model change, and
270
+ // the tier is never silent (trigger badge + options switch both show
271
+ // it). It resets ONLY when the new model prices no fast variant: a
272
+ // stale tier there would be refused at create time (#357).
273
+ if (
274
+ serviceTier === "fast"
275
+ && onServiceTierChange
276
+ && !model.serviceTiers.includes(FAST_SERVICE_TIER)
277
+ ) {
278
+ onServiceTierChange("standard");
279
+ }
219
280
  setOpen(false);
220
281
  },
221
- [onValueChange],
282
+ [onValueChange, serviceTier, onServiceTierChange],
222
283
  );
223
284
 
224
285
  const scrollHighlightIntoView = useCallback((idx: number) => {
@@ -229,7 +290,7 @@ export function ModelSelector({
229
290
  }, []);
230
291
 
231
292
  const handleKeyDown = useCallback(
232
- (e: React.KeyboardEvent) => {
293
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
233
294
  const len = visibleModels.length;
234
295
  if (len === 0) return;
235
296
 
@@ -265,8 +326,27 @@ export function ModelSelector({
265
326
 
266
327
  const showShowAllButton = !isSearching && !showAll && featuredModels.length > 0 && featuredModels.length < models.length;
267
328
 
268
- const triggerLabel = selectedModel?.displayName ?? "Select model";
269
- const triggerHarness = !isHarnessLocked ? HARNESS_META[activeHarness].label : undefined;
329
+ // The options-area switch exists only where it applies: a consumer opted
330
+ // in AND the selected model prices a fast variant (recognition over
331
+ // recall — never a dead control on models with no tier choice).
332
+ const showServiceTierToggle =
333
+ onServiceTierChange !== undefined
334
+ && selectedModel !== undefined
335
+ && selectedModel.serviceTiers.includes(FAST_SERVICE_TIER);
336
+ // The trigger badge is display-only: fast is in effect iff the tier is
337
+ // fast AND the selected model actually prices the variant.
338
+ const fastActive =
339
+ serviceTier === "fast"
340
+ && (selectedModel?.serviceTiers.includes(FAST_SERVICE_TIER) ?? false);
341
+
342
+ const usingPlaceholder = !value && placeholderLabel !== undefined;
343
+ const triggerLabel = usingPlaceholder
344
+ ? placeholderLabel
345
+ : (selectedModel?.displayName ?? "Select model");
346
+ const triggerHarness =
347
+ !isHarnessLocked && !usingPlaceholder
348
+ ? HARNESS_META[activeHarness].label
349
+ : undefined;
270
350
 
271
351
  return (
272
352
  <Popover.Root open={open} onOpenChange={setOpen}>
@@ -288,6 +368,17 @@ export function ModelSelector({
288
368
  <span className="shrink-0 text-border" aria-hidden>·</span>
289
369
  )}
290
370
  <span className="truncate">{triggerLabel}</span>
371
+ {fastActive && (
372
+ <span
373
+ className={cn(
374
+ "inline-flex shrink-0 items-center gap-0.5 rounded-full border border-primary px-1.5 py-px",
375
+ "text-[0.6rem] font-medium uppercase tracking-wider text-primary",
376
+ )}
377
+ >
378
+ <BoltIcon />
379
+ Fast
380
+ </span>
381
+ )}
291
382
  <ChevronIcon />
292
383
  </Popover.Trigger>
293
384
 
@@ -369,6 +460,29 @@ export function ModelSelector({
369
460
  )}
370
461
  </div>
371
462
 
463
+ {/* Options area — the Cursor options-panel convention: settings
464
+ of the CURRENT selection sit at the top of the popover, the
465
+ first thing visible on open, never buried under the list.
466
+ Rendered only while the selected model prices a fast
467
+ variant (#357). */}
468
+ {showServiceTierToggle && (
469
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
470
+ <div className="flex flex-col">
471
+ <span className="text-xs text-foreground">Fast tier</span>
472
+ <span className="text-[0.65rem] text-muted-foreground">
473
+ Faster responses at higher per-token rates
474
+ </span>
475
+ </div>
476
+ <Switch
477
+ checked={fastActive}
478
+ onCheckedChange={(next) =>
479
+ onServiceTierChange?.(next ? "fast" : "standard")
480
+ }
481
+ aria-label="Fast tier"
482
+ />
483
+ </div>
484
+ )}
485
+
372
486
  {/* Search input */}
373
487
  <div className="border-b border-border px-3 py-1.5">
374
488
  <input
@@ -568,3 +682,18 @@ function CheckIcon({ className }: { className?: string }) {
568
682
  </svg>
569
683
  );
570
684
  }
685
+
686
+ function BoltIcon() {
687
+ return (
688
+ <svg
689
+ className="shrink-0"
690
+ width="8"
691
+ height="10"
692
+ viewBox="0 0 8 10"
693
+ fill="currentColor"
694
+ aria-hidden
695
+ >
696
+ <path d="M4.9 0.2L0.6 5.6h2.6L3.1 9.8l4.3-5.4H4.8L4.9 0.2z" />
697
+ </svg>
698
+ );
699
+ }
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import { ModelSelector } from "../ModelSelector";
4
+ import { ModelRegistryContext } from "../ModelRegistryContext";
5
+ import type { ModelInfo } from "../registry";
6
+
7
+ // Portal into document.body so the popover mounts under happy-dom, and shim
8
+ // ResizeObserver for Base UI's positioner (the scheduleCreation.test.tsx
9
+ // pattern).
10
+ vi.mock("../../portal-container", () => ({
11
+ useStigmerPortalContainer: () => document.body,
12
+ }));
13
+
14
+ beforeAll(() => {
15
+ if (!("ResizeObserver" in globalThis)) {
16
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
17
+ class {
18
+ observe() {}
19
+ unobserve() {}
20
+ disconnect() {}
21
+ };
22
+ }
23
+ });
24
+
25
+ /**
26
+ * Fast-tier switch in the popover's options area (stigmer/stigmer#357).
27
+ *
28
+ * The switch sits at the TOP of the popover (the Cursor options-panel
29
+ * convention) and is capability-gated: it renders only when the consumer
30
+ * opted in (onServiceTierChange) AND the selected model prices a fast
31
+ * variant (ModelInfo.serviceTiers) — no dead controls. An active fast
32
+ * tier persists across switches between fast-capable models (the trigger
33
+ * badge and the switch keep the state visible), and resets ONLY when the
34
+ * chosen model prices no fast variant — a stale tier there would be
35
+ * refused at create time.
36
+ */
37
+
38
+ const FAST_CAPABLE: ModelInfo = {
39
+ modelId: "composer-2.5",
40
+ provider: "cursor",
41
+ displayName: "Composer 2.5",
42
+ shortDescription: "Fast agentic model",
43
+ speedTier: "fastest",
44
+ costTier: "economy",
45
+ harness: "cursor",
46
+ featured: true,
47
+ serviceTiers: ["fast"],
48
+ };
49
+
50
+ const ALSO_FAST_CAPABLE: ModelInfo = {
51
+ modelId: "claude-opus-4-8",
52
+ provider: "anthropic",
53
+ displayName: "Claude 4.8 Opus",
54
+ shortDescription: "Frontier Opus via Cursor",
55
+ speedTier: "slow",
56
+ costTier: "premium",
57
+ harness: "cursor",
58
+ featured: true,
59
+ serviceTiers: ["fast"],
60
+ };
61
+
62
+ const NO_FAST_TIER: ModelInfo = {
63
+ modelId: "claude-haiku-4-5",
64
+ provider: "anthropic",
65
+ displayName: "Haiku 4.5",
66
+ shortDescription: "Small fast model",
67
+ speedTier: "fastest",
68
+ costTier: "economy",
69
+ harness: "cursor",
70
+ featured: true,
71
+ serviceTiers: [],
72
+ };
73
+
74
+ function renderSelector(
75
+ props?: Partial<React.ComponentProps<typeof ModelSelector>>,
76
+ ) {
77
+ const onValueChange = vi.fn();
78
+ const onServiceTierChange = vi.fn();
79
+
80
+ const result = render(
81
+ <ModelRegistryContext.Provider
82
+ value={{
83
+ models: [FAST_CAPABLE, ALSO_FAST_CAPABLE, NO_FAST_TIER],
84
+ isLoading: false,
85
+ error: null,
86
+ refetch: vi.fn(),
87
+ }}
88
+ >
89
+ <ModelSelector
90
+ value="composer-2.5"
91
+ onValueChange={onValueChange}
92
+ harness="cursor"
93
+ serviceTier="standard"
94
+ onServiceTierChange={onServiceTierChange}
95
+ {...props}
96
+ />
97
+ </ModelRegistryContext.Provider>,
98
+ );
99
+
100
+ return { ...result, onValueChange, onServiceTierChange };
101
+ }
102
+
103
+ /**
104
+ * Open the popover via its trigger and wait for the portaled popup to
105
+ * mount (Base UI portals render asynchronously in jsdom). The search
106
+ * input is the popup's always-present landmark.
107
+ */
108
+ async function openPopover(triggerLabel: string) {
109
+ fireEvent.click(screen.getByRole("button", { name: new RegExp(triggerLabel) }));
110
+ await screen.findByPlaceholderText("Search models…");
111
+ }
112
+
113
+ /** A model row inside the popup list (never matches the trigger). */
114
+ function modelOption(displayName: string): HTMLElement {
115
+ const option = screen
116
+ .getAllByText(displayName)
117
+ .map((el) => el.closest<HTMLElement>("[data-model-option]"))
118
+ .find((el) => el != null);
119
+ if (!option) throw new Error(`No popup option for "${displayName}"`);
120
+ return option;
121
+ }
122
+
123
+ afterEach(cleanup);
124
+
125
+ describe("ModelSelector — fast tier switch in the options area", () => {
126
+ it("renders the switch for a fast-capable selected model", async () => {
127
+ renderSelector();
128
+ await openPopover("Composer 2.5");
129
+
130
+ const toggle = screen.getByRole("switch", { name: "Fast tier" });
131
+ expect(toggle.getAttribute("aria-checked")).toBe("false");
132
+ });
133
+
134
+ it("places the switch in the options area above the model list", async () => {
135
+ renderSelector();
136
+ await openPopover("Composer 2.5");
137
+
138
+ const toggle = screen.getByRole("switch", { name: "Fast tier" });
139
+ const search = screen.getByRole("searchbox", { name: "Search models" });
140
+ // DOCUMENT_POSITION_FOLLOWING: the search input comes after the switch,
141
+ // i.e. the options area precedes search + list (never a footer).
142
+ expect(
143
+ toggle.compareDocumentPosition(search)
144
+ & Node.DOCUMENT_POSITION_FOLLOWING,
145
+ ).toBeTruthy();
146
+ });
147
+
148
+ it("does not render the switch for a model without a fast tier", async () => {
149
+ renderSelector({ value: "claude-haiku-4-5" });
150
+ await openPopover("Haiku 4.5");
151
+
152
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
153
+ });
154
+
155
+ it("does not render the switch when the consumer did not opt in", async () => {
156
+ renderSelector({ onServiceTierChange: undefined });
157
+ await openPopover("Composer 2.5");
158
+
159
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
160
+ });
161
+
162
+ it("toggling fires onServiceTierChange with the flipped tier", async () => {
163
+ const { onServiceTierChange } = renderSelector();
164
+ await openPopover("Composer 2.5");
165
+
166
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
167
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
168
+ expect(onServiceTierChange).toHaveBeenCalledWith("fast");
169
+ });
170
+
171
+ it("toggling off an active fast tier fires 'standard'", async () => {
172
+ const { onServiceTierChange } = renderSelector({ serviceTier: "fast" });
173
+ await openPopover("Composer 2.5");
174
+
175
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
176
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
177
+ expect(onServiceTierChange).toHaveBeenCalledWith("standard");
178
+ });
179
+
180
+ it("keeps the popover open across a toggle", async () => {
181
+ renderSelector();
182
+ await openPopover("Composer 2.5");
183
+
184
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
185
+ expect(screen.getByPlaceholderText("Search models…")).toBeTruthy();
186
+ });
187
+ });
188
+
189
+ describe("ModelSelector — trigger badge", () => {
190
+ it("shows the Fast badge on the trigger when the fast tier is active", () => {
191
+ renderSelector({ serviceTier: "fast" });
192
+
193
+ expect(screen.getByText("Fast")).toBeTruthy();
194
+ });
195
+
196
+ it("hides the badge when the selected model has no fast tier", () => {
197
+ renderSelector({ serviceTier: "fast", value: "claude-haiku-4-5" });
198
+
199
+ expect(screen.queryByText("Fast")).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe("ModelSelector — fast tier persistence across model switches", () => {
204
+ it("selecting a model while standard emits no tier change", async () => {
205
+ const { onValueChange, onServiceTierChange } = renderSelector();
206
+ await openPopover("Composer 2.5");
207
+
208
+ fireEvent.click(modelOption("Composer 2.5"));
209
+
210
+ expect(onValueChange).toHaveBeenCalledWith("composer-2.5");
211
+ expect(onServiceTierChange).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it("keeps an active fast tier when selecting another fast-capable model", async () => {
215
+ // "Fast, but on that other model" is one action — resetting here made
216
+ // users re-toggle on every model change, and the tier is never silent
217
+ // (trigger badge + options switch both show it).
218
+ const { onValueChange, onServiceTierChange } = renderSelector({
219
+ serviceTier: "fast",
220
+ });
221
+ await openPopover("Composer 2.5");
222
+
223
+ fireEvent.click(modelOption("Claude 4.8 Opus"));
224
+
225
+ expect(onValueChange).toHaveBeenCalledWith("claude-opus-4-8");
226
+ expect(onServiceTierChange).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("toggle-then-pick works in a single popover visit", async () => {
230
+ // Flip the switch first (popover stays open), then pick a fast-capable
231
+ // model: the toggle survives the selection.
232
+ const { onValueChange, onServiceTierChange } = renderSelector();
233
+ await openPopover("Composer 2.5");
234
+
235
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
236
+ fireEvent.click(modelOption("Claude 4.8 Opus"));
237
+
238
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
239
+ expect(onServiceTierChange).toHaveBeenCalledWith("fast");
240
+ expect(onValueChange).toHaveBeenCalledWith("claude-opus-4-8");
241
+ });
242
+
243
+ it("resets an active fast tier when selecting a model without one", async () => {
244
+ const { onValueChange, onServiceTierChange } = renderSelector({
245
+ serviceTier: "fast",
246
+ });
247
+ await openPopover("Composer 2.5");
248
+
249
+ fireEvent.click(modelOption("Haiku 4.5"));
250
+
251
+ expect(onValueChange).toHaveBeenCalledOnce();
252
+ expect(onValueChange).toHaveBeenCalledWith("claude-haiku-4-5");
253
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
254
+ expect(onServiceTierChange).toHaveBeenCalledWith("standard");
255
+ });
256
+ });
@@ -73,6 +73,9 @@ const TEST_REGISTRY_JSON = {
73
73
  costTier: "standard",
74
74
  featured: false,
75
75
  pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 },
76
+ pricingVariants: {
77
+ fast: { inputPricePerMillion: 18, outputPricePerMillion: 90 },
78
+ },
76
79
  },
77
80
  {
78
81
  id: "ollama-local",
@@ -103,6 +106,18 @@ function createWrapper(models: readonly ModelInfo[] = TEST_MODELS) {
103
106
  };
104
107
  }
105
108
 
109
+ describe("parseRegistryJson service tiers (#357)", () => {
110
+ it("surfaces pricingVariants keys as serviceTiers", () => {
111
+ const withVariants = TEST_MODELS.find((m) => m.modelId === "claude-4.6-sonnet");
112
+ expect(withVariants?.serviceTiers).toEqual(["fast"]);
113
+ });
114
+
115
+ it("yields an empty serviceTiers list for models without variants", () => {
116
+ const withoutVariants = TEST_MODELS.find((m) => m.modelId === "claude-sonnet-4.6");
117
+ expect(withoutVariants?.serviceTiers).toEqual([]);
118
+ });
119
+ });
120
+
106
121
  describe("useModelRegistry", () => {
107
122
  describe("unified mode (no harness)", () => {
108
123
  it("excludes DISABLED_PROVIDERS from models", () => {
@@ -11,3 +11,5 @@ export { HarnessSelector } from "./HarnessSelector.js";
11
11
  export type { HarnessSelectorProps } from "./HarnessSelector.js";
12
12
  export { DEFAULT_HARNESS, HARNESS_LABELS, HARNESS_META, HARNESS_OPTIONS, toProtoHarness, fromProtoHarness } from "./harness.js";
13
13
  export type { HarnessOption, HarnessDisplayInfo } from "./harness.js";
14
+ export { FAST_SERVICE_TIER, toProtoServiceTier, fromProtoServiceTier } from "./service-tier.js";
15
+ export type { ServiceTierOption } from "./service-tier.js";
@@ -89,6 +89,17 @@ export interface ModelInfo {
89
89
  * before "Show All Models" is expanded or search is used).
90
90
  */
91
91
  readonly featured: boolean;
92
+ /**
93
+ * Pricing-variant keys the registry prices for this model (e.g.
94
+ * `["fast"]`). A priced variant is a *selectable* service tier
95
+ * (stigmer/stigmer#357): the fast-tier switch in the model popover's
96
+ * options area renders only while the selected model's `serviceTiers`
97
+ * includes it. Empty for models with no variants.
98
+ *
99
+ * Distinct from {@link speedTier}, which is a static latency badge,
100
+ * not a selectable option.
101
+ */
102
+ readonly serviceTiers: readonly string[];
92
103
  }
93
104
 
94
105
  /**
@@ -153,6 +164,8 @@ interface RegistryJsonEntry {
153
164
  cacheWritePricePerMillion: number;
154
165
  cacheReadPricePerMillion: number;
155
166
  };
167
+ /** Variant-key → variant pricing block; only the key set matters here. */
168
+ pricingVariants?: Record<string, unknown>;
156
169
  $comment?: string;
157
170
  }
158
171
 
@@ -192,6 +205,10 @@ export function parseRegistryJson(data: unknown): ModelInfo[] {
192
205
  costTier: m.costTier as CostTier,
193
206
  harness: m.harness as HarnessOption,
194
207
  featured: m.featured ?? false,
208
+ serviceTiers:
209
+ m.pricingVariants && typeof m.pricingVariants === "object"
210
+ ? Object.keys(m.pricingVariants).sort()
211
+ : [],
195
212
  }));
196
213
  }
197
214
 
@@ -0,0 +1,49 @@
1
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
2
+
3
+ /**
4
+ * String form of the service tier for component props and hook inputs
5
+ * (stigmer/stigmer#357). Mirrors {@link HarnessOption}: components speak
6
+ * strings, execution creation speaks proto.
7
+ *
8
+ * "standard" is the model's base-priced configuration; "fast" is the
9
+ * provider's fast variant at the registry's fast rates — selectable only
10
+ * for models whose {@link ModelInfo.serviceTiers} includes "fast".
11
+ */
12
+ export type ServiceTierOption = "standard" | "fast";
13
+
14
+ /** The tier key a model must price for the fast-tier switch to render. */
15
+ export const FAST_SERVICE_TIER = "fast";
16
+
17
+ /**
18
+ * Convert a {@link ServiceTierOption} string to the proto enum.
19
+ */
20
+ export function toProtoServiceTier(tier: ServiceTierOption): ServiceTier {
21
+ switch (tier) {
22
+ case "fast":
23
+ return ServiceTier.FAST;
24
+ case "standard":
25
+ default:
26
+ return ServiceTier.STANDARD;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Convert a proto {@link ServiceTier} enum to its string form.
32
+ *
33
+ * `UNSPECIFIED` (and any unknown value) maps to `undefined` so callers can
34
+ * decide their own default — matching the proto contract (`UNSPECIFIED`
35
+ * resolves to `SERVICE_TIER_STANDARD` in the runner, never the provider
36
+ * account default).
37
+ */
38
+ export function fromProtoServiceTier(
39
+ tier: ServiceTier | undefined,
40
+ ): ServiceTierOption | undefined {
41
+ switch (tier) {
42
+ case ServiceTier.STANDARD:
43
+ return "standard";
44
+ case ServiceTier.FAST:
45
+ return "fast";
46
+ default:
47
+ return undefined;
48
+ }
49
+ }