@stigmer/react 3.7.0 → 3.9.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 (219) hide show
  1. package/attachment/AttachmentChipList.d.ts.map +1 -1
  2. package/attachment/AttachmentChipList.js +30 -1
  3. package/attachment/AttachmentChipList.js.map +1 -1
  4. package/attachment/attachment-utils.d.ts +11 -0
  5. package/attachment/attachment-utils.d.ts.map +1 -1
  6. package/attachment/attachment-utils.js +23 -0
  7. package/attachment/attachment-utils.js.map +1 -1
  8. package/attachment/clipboard.d.ts +51 -0
  9. package/attachment/clipboard.d.ts.map +1 -0
  10. package/attachment/clipboard.js +89 -0
  11. package/attachment/clipboard.js.map +1 -0
  12. package/attachment/index.d.ts +6 -1
  13. package/attachment/index.d.ts.map +1 -1
  14. package/attachment/index.js +4 -1
  15. package/attachment/index.js.map +1 -1
  16. package/attachment/prepare-image.d.ts +43 -0
  17. package/attachment/prepare-image.d.ts.map +1 -0
  18. package/attachment/prepare-image.js +162 -0
  19. package/attachment/prepare-image.js.map +1 -0
  20. package/attachment/useAttachments.d.ts.map +1 -1
  21. package/attachment/useAttachments.js +23 -3
  22. package/attachment/useAttachments.js.map +1 -1
  23. package/attachment/vision-fit.d.ts +50 -0
  24. package/attachment/vision-fit.d.ts.map +1 -0
  25. package/attachment/vision-fit.js +76 -0
  26. package/attachment/vision-fit.js.map +1 -0
  27. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  28. package/channel/AgentChannelsPanel.js +21 -2
  29. package/channel/AgentChannelsPanel.js.map +1 -1
  30. package/channel/ChannelConversationsDialog.d.ts +12 -4
  31. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  32. package/channel/ChannelConversationsDialog.js +18 -24
  33. package/channel/ChannelConversationsDialog.js.map +1 -1
  34. package/channel/providers.d.ts +23 -0
  35. package/channel/providers.d.ts.map +1 -1
  36. package/channel/providers.js +4 -0
  37. package/channel/providers.js.map +1 -1
  38. package/channel/useSaveAgentChannel.d.ts +5 -4
  39. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  40. package/channel/useSaveAgentChannel.js +18 -4
  41. package/channel/useSaveAgentChannel.js.map +1 -1
  42. package/composer/ComposerToolbar.d.ts +1 -1
  43. package/composer/ComposerToolbar.d.ts.map +1 -1
  44. package/composer/SessionComposer.d.ts +4 -2
  45. package/composer/SessionComposer.d.ts.map +1 -1
  46. package/composer/SessionComposer.js +51 -5
  47. package/composer/SessionComposer.js.map +1 -1
  48. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  49. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  50. package/conversation/ConversationAttentionBanner.js +25 -0
  51. package/conversation/ConversationAttentionBanner.js.map +1 -0
  52. package/conversation/ConversationComposer.d.ts +41 -0
  53. package/conversation/ConversationComposer.d.ts.map +1 -0
  54. package/conversation/ConversationComposer.js +57 -0
  55. package/conversation/ConversationComposer.js.map +1 -0
  56. package/conversation/ConversationControlBanner.d.ts +52 -0
  57. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  58. package/conversation/ConversationControlBanner.js +59 -0
  59. package/conversation/ConversationControlBanner.js.map +1 -0
  60. package/conversation/ConversationListPane.d.ts +68 -0
  61. package/conversation/ConversationListPane.d.ts.map +1 -0
  62. package/conversation/ConversationListPane.js +120 -0
  63. package/conversation/ConversationListPane.js.map +1 -0
  64. package/conversation/ConversationTimelineView.d.ts +46 -0
  65. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  66. package/conversation/ConversationTimelineView.js +166 -0
  67. package/conversation/ConversationTimelineView.js.map +1 -0
  68. package/conversation/ConversationsWorkbench.d.ts +63 -0
  69. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  70. package/conversation/ConversationsWorkbench.js +165 -0
  71. package/conversation/ConversationsWorkbench.js.map +1 -0
  72. package/conversation/conversationPresentation.d.ts +137 -0
  73. package/conversation/conversationPresentation.d.ts.map +1 -0
  74. package/conversation/conversationPresentation.js +230 -0
  75. package/conversation/conversationPresentation.js.map +1 -0
  76. package/conversation/index.d.ts +26 -0
  77. package/conversation/index.d.ts.map +1 -0
  78. package/conversation/index.js +14 -0
  79. package/conversation/index.js.map +1 -0
  80. package/conversation/polling.d.ts +39 -0
  81. package/conversation/polling.d.ts.map +1 -0
  82. package/conversation/polling.js +39 -0
  83. package/conversation/polling.js.map +1 -0
  84. package/conversation/useConversation.d.ts +61 -0
  85. package/conversation/useConversation.d.ts.map +1 -0
  86. package/conversation/useConversation.js +153 -0
  87. package/conversation/useConversation.js.map +1 -0
  88. package/conversation/useConversationList.d.ts +98 -0
  89. package/conversation/useConversationList.d.ts.map +1 -0
  90. package/conversation/useConversationList.js +200 -0
  91. package/conversation/useConversationList.js.map +1 -0
  92. package/conversation/useConversationParticipation.d.ts +63 -0
  93. package/conversation/useConversationParticipation.d.ts.map +1 -0
  94. package/conversation/useConversationParticipation.js +84 -0
  95. package/conversation/useConversationParticipation.js.map +1 -0
  96. package/conversation/useConversationTimeline.d.ts +71 -0
  97. package/conversation/useConversationTimeline.d.ts.map +1 -0
  98. package/conversation/useConversationTimeline.js +152 -0
  99. package/conversation/useConversationTimeline.js.map +1 -0
  100. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  101. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  102. package/conversation/useConversationsWantsHumanCount.js +40 -0
  103. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  104. package/index.d.ts +6 -2
  105. package/index.d.ts.map +1 -1
  106. package/index.js +11 -2
  107. package/index.js.map +1 -1
  108. package/internal/tooltip.d.ts.map +1 -1
  109. package/internal/tooltip.js +7 -1
  110. package/internal/tooltip.js.map +1 -1
  111. package/internal/useAutoScroll.d.ts +25 -4
  112. package/internal/useAutoScroll.d.ts.map +1 -1
  113. package/internal/useAutoScroll.js +55 -15
  114. package/internal/useAutoScroll.js.map +1 -1
  115. package/internal/useFetch.d.ts.map +1 -1
  116. package/internal/useFetch.js +14 -4
  117. package/internal/useFetch.js.map +1 -1
  118. package/models/ModelSelector.d.ts +24 -5
  119. package/models/ModelSelector.d.ts.map +1 -1
  120. package/models/ModelSelector.js +31 -9
  121. package/models/ModelSelector.js.map +1 -1
  122. package/models/registry.d.ts +3 -2
  123. package/models/registry.d.ts.map +1 -1
  124. package/models/registry.js.map +1 -1
  125. package/models/service-tier.d.ts +1 -1
  126. package/models/service-tier.d.ts.map +1 -1
  127. package/models/service-tier.js +1 -1
  128. package/models/service-tier.js.map +1 -1
  129. package/package.json +4 -4
  130. package/schedule/ScheduleDetailView.js +1 -1
  131. package/schedule/ScheduleDetailView.js.map +1 -1
  132. package/schedule/ScheduleForm.d.ts.map +1 -1
  133. package/schedule/ScheduleForm.js +7 -6
  134. package/schedule/ScheduleForm.js.map +1 -1
  135. package/sharing/useSaveAgentShare.d.ts +10 -1
  136. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  137. package/sharing/useSaveAgentShare.js +9 -0
  138. package/sharing/useSaveAgentShare.js.map +1 -1
  139. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  140. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  141. package/sidebar/WorkspaceSidebar.js +19 -7
  142. package/sidebar/WorkspaceSidebar.js.map +1 -1
  143. package/src/attachment/AttachmentChipList.tsx +45 -1
  144. package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
  145. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  146. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  147. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  148. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  149. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  150. package/src/attachment/attachment-utils.ts +27 -0
  151. package/src/attachment/clipboard.ts +102 -0
  152. package/src/attachment/index.ts +13 -0
  153. package/src/attachment/prepare-image.ts +179 -0
  154. package/src/attachment/useAttachments.ts +26 -2
  155. package/src/attachment/vision-fit.ts +90 -0
  156. package/src/channel/AgentChannelsPanel.tsx +33 -2
  157. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  158. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  159. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  160. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  161. package/src/channel/providers.ts +27 -0
  162. package/src/channel/useSaveAgentChannel.ts +18 -4
  163. package/src/composer/ComposerToolbar.tsx +1 -1
  164. package/src/composer/SessionComposer.tsx +75 -6
  165. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  166. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  167. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  168. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  169. package/src/conversation/ConversationComposer.tsx +156 -0
  170. package/src/conversation/ConversationControlBanner.tsx +193 -0
  171. package/src/conversation/ConversationListPane.tsx +459 -0
  172. package/src/conversation/ConversationTimelineView.tsx +435 -0
  173. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  174. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  175. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  176. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  177. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  178. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  179. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  180. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  181. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  182. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  183. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  184. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  185. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  186. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  187. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  188. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  189. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  190. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  191. package/src/conversation/conversationPresentation.ts +305 -0
  192. package/src/conversation/index.ts +80 -0
  193. package/src/conversation/polling.ts +41 -0
  194. package/src/conversation/useConversation.ts +221 -0
  195. package/src/conversation/useConversationList.ts +328 -0
  196. package/src/conversation/useConversationParticipation.ts +178 -0
  197. package/src/conversation/useConversationTimeline.ts +244 -0
  198. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  199. package/src/index.ts +78 -1
  200. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  201. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  202. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  203. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  204. package/src/internal/tooltip.tsx +6 -1
  205. package/src/internal/useAutoScroll.ts +66 -20
  206. package/src/internal/useFetch.ts +15 -4
  207. package/src/models/ModelSelector.tsx +81 -49
  208. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  209. package/src/models/registry.ts +3 -2
  210. package/src/models/service-tier.ts +1 -1
  211. package/src/schedule/ScheduleDetailView.tsx +1 -1
  212. package/src/schedule/ScheduleForm.tsx +7 -6
  213. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  214. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  215. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  216. package/src/sharing/useSaveAgentShare.ts +19 -1
  217. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  218. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  219. package/styles.css +1 -1
@@ -4,6 +4,7 @@ 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";
@@ -80,11 +81,17 @@ export interface ModelSelectorProps {
80
81
  readonly serviceTier?: ServiceTierOption;
81
82
  /**
82
83
  * Called when the user toggles the fast tier. Providing this enables the
83
- * tier toggle, which renders ONLY when the selected model prices a fast
84
- * variant ({@link ModelInfo.serviceTiers})no dead controls. Selecting
85
- * a model without a fast tier while "fast" is active resets the tier to
86
- * "standard" through this callback, so an unsupported combination can
87
- * never be submitted.
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).
88
95
  */
89
96
  readonly onServiceTierChange?: (tier: ServiceTierOption) => void;
90
97
 
@@ -105,6 +112,19 @@ export interface ModelSelectorProps {
105
112
  * The trigger button displays the current selection in compact format:
106
113
  * `Harness · Model Name ▾` (or just `Model Name ▾` when harness is locked).
107
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
+ *
108
128
  * @example
109
129
  * ```tsx
110
130
  * <ModelSelector
@@ -245,9 +265,11 @@ export function ModelSelector({
245
265
  const selectModel = useCallback(
246
266
  (model: ModelInfo) => {
247
267
  onValueChange(model.modelId);
248
- // Fail-safe: the fast tier is per-model; carrying it onto a model
249
- // that does not price it would be refused at create time (#357), so
250
- // reset here instead of surfacing a dead selection.
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).
251
273
  if (
252
274
  serviceTier === "fast"
253
275
  && onServiceTierChange
@@ -268,7 +290,7 @@ export function ModelSelector({
268
290
  }, []);
269
291
 
270
292
  const handleKeyDown = useCallback(
271
- (e: React.KeyboardEvent) => {
293
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
272
294
  const len = visibleModels.length;
273
295
  if (len === 0) return;
274
296
 
@@ -304,14 +326,18 @@ export function ModelSelector({
304
326
 
305
327
  const showShowAllButton = !isSearching && !showAll && featuredModels.length > 0 && featuredModels.length < models.length;
306
328
 
307
- // The tier toggle exists only where it applies: a consumer opted in AND
308
- // the selected model prices a fast variant (recognition over recall —
309
- // never a dead control on models with no tier choice).
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).
310
332
  const showServiceTierToggle =
311
333
  onServiceTierChange !== undefined
312
334
  && selectedModel !== undefined
313
335
  && selectedModel.serviceTiers.includes(FAST_SERVICE_TIER);
314
- const fastActive = serviceTier === "fast";
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);
315
341
 
316
342
  const usingPlaceholder = !value && placeholderLabel !== undefined;
317
343
  const triggerLabel = usingPlaceholder
@@ -342,13 +368,14 @@ export function ModelSelector({
342
368
  <span className="shrink-0 text-border" aria-hidden>·</span>
343
369
  )}
344
370
  <span className="truncate">{triggerLabel}</span>
345
- {showServiceTierToggle && fastActive && (
371
+ {fastActive && (
346
372
  <span
347
373
  className={cn(
348
- "shrink-0 rounded-sm border border-border px-1 py-px",
349
- "text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground",
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",
350
376
  )}
351
377
  >
378
+ <BoltIcon />
352
379
  Fast
353
380
  </span>
354
381
  )}
@@ -433,6 +460,29 @@ export function ModelSelector({
433
460
  )}
434
461
  </div>
435
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
+
436
486
  {/* Search input */}
437
487
  <div className="border-b border-border px-3 py-1.5">
438
488
  <input
@@ -535,39 +585,6 @@ export function ModelSelector({
535
585
  </button>
536
586
  )}
537
587
  </div>
538
-
539
- {/* Service tier toggle — only for models that price a fast variant (#357) */}
540
- {showServiceTierToggle && (
541
- <div className="flex items-center justify-between border-t border-border px-3 py-2">
542
- <div className="flex flex-col">
543
- <span className="text-xs text-foreground">Fast tier</span>
544
- <span className="text-[0.65rem] text-muted-foreground">
545
- Faster responses at higher per-token rates
546
- </span>
547
- </div>
548
- <button
549
- type="button"
550
- role="switch"
551
- aria-checked={fastActive}
552
- aria-label="Fast tier"
553
- className={cn(
554
- "relative h-4.5 w-8 shrink-0 rounded-full transition-colors cursor-pointer",
555
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
556
- fastActive ? "bg-primary" : "bg-muted",
557
- )}
558
- onClick={() =>
559
- onServiceTierChange?.(fastActive ? "standard" : "fast")
560
- }
561
- >
562
- <span
563
- className={cn(
564
- "absolute top-0.5 size-3.5 rounded-full bg-background shadow-sm transition-transform",
565
- fastActive ? "translate-x-4" : "translate-x-0.5",
566
- )}
567
- />
568
- </button>
569
- </div>
570
- )}
571
588
  </Popover.Popup>
572
589
  </Popover.Positioner>
573
590
  </Popover.Portal>
@@ -665,3 +682,18 @@ function CheckIcon({ className }: { className?: string }) {
665
682
  </svg>
666
683
  );
667
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
+ }
@@ -23,12 +23,16 @@ beforeAll(() => {
23
23
  });
24
24
 
25
25
  /**
26
- * Service-tier toggle gating (stigmer/stigmer#357).
26
+ * Fast-tier switch in the popover's options area (stigmer/stigmer#357).
27
27
  *
28
- * The toggle is capability-gated: it renders only when the consumer opted in
29
- * (onServiceTierChange) AND the selected model prices a fast variant
30
- * (ModelInfo.serviceTiers). Switching to a model without a fast tier resets
31
- * an active fast selection a stale tier would be refused at create time.
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.
32
36
  */
33
37
 
34
38
  const FAST_CAPABLE: ModelInfo = {
@@ -43,6 +47,18 @@ const FAST_CAPABLE: ModelInfo = {
43
47
  serviceTiers: ["fast"],
44
48
  };
45
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
+
46
62
  const NO_FAST_TIER: ModelInfo = {
47
63
  modelId: "claude-haiku-4-5",
48
64
  provider: "anthropic",
@@ -64,7 +80,7 @@ function renderSelector(
64
80
  const result = render(
65
81
  <ModelRegistryContext.Provider
66
82
  value={{
67
- models: [FAST_CAPABLE, NO_FAST_TIER],
83
+ models: [FAST_CAPABLE, ALSO_FAST_CAPABLE, NO_FAST_TIER],
68
84
  isLoading: false,
69
85
  error: null,
70
86
  refetch: vi.fn(),
@@ -106,8 +122,8 @@ function modelOption(displayName: string): HTMLElement {
106
122
 
107
123
  afterEach(cleanup);
108
124
 
109
- describe("ModelSelector — service tier toggle", () => {
110
- it("renders the toggle for a fast-capable model", async () => {
125
+ describe("ModelSelector — fast tier switch in the options area", () => {
126
+ it("renders the switch for a fast-capable selected model", async () => {
111
127
  renderSelector();
112
128
  await openPopover("Composer 2.5");
113
129
 
@@ -115,14 +131,28 @@ describe("ModelSelector — service tier toggle", () => {
115
131
  expect(toggle.getAttribute("aria-checked")).toBe("false");
116
132
  });
117
133
 
118
- it("does not render the toggle for a model without a fast tier", async () => {
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 () => {
119
149
  renderSelector({ value: "claude-haiku-4-5" });
120
150
  await openPopover("Haiku 4.5");
121
151
 
122
152
  expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
123
153
  });
124
154
 
125
- it("does not render the toggle when the consumer did not opt in", async () => {
155
+ it("does not render the switch when the consumer did not opt in", async () => {
126
156
  renderSelector({ onServiceTierChange: undefined });
127
157
  await openPopover("Composer 2.5");
128
158
 
@@ -138,35 +168,89 @@ describe("ModelSelector — service tier toggle", () => {
138
168
  expect(onServiceTierChange).toHaveBeenCalledWith("fast");
139
169
  });
140
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", () => {
141
190
  it("shows the Fast badge on the trigger when the fast tier is active", () => {
142
191
  renderSelector({ serviceTier: "fast" });
143
192
 
144
193
  expect(screen.getByText("Fast")).toBeTruthy();
145
194
  });
146
195
 
147
- it("resets an active fast tier when selecting a model without one", async () => {
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).
148
218
  const { onValueChange, onServiceTierChange } = renderSelector({
149
219
  serviceTier: "fast",
150
220
  });
151
221
  await openPopover("Composer 2.5");
152
222
 
153
- fireEvent.click(modelOption("Haiku 4.5"));
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"));
154
237
 
155
- expect(onValueChange).toHaveBeenCalledOnce();
156
- expect(onValueChange).toHaveBeenCalledWith("claude-haiku-4-5");
157
238
  expect(onServiceTierChange).toHaveBeenCalledOnce();
158
- expect(onServiceTierChange).toHaveBeenCalledWith("standard");
239
+ expect(onServiceTierChange).toHaveBeenCalledWith("fast");
240
+ expect(onValueChange).toHaveBeenCalledWith("claude-opus-4-8");
159
241
  });
160
242
 
161
- it("keeps an active fast tier when selecting another fast-capable model", async () => {
162
- const { onServiceTierChange } = renderSelector({
243
+ it("resets an active fast tier when selecting a model without one", async () => {
244
+ const { onValueChange, onServiceTierChange } = renderSelector({
163
245
  serviceTier: "fast",
164
- value: "claude-haiku-4-5",
165
246
  });
166
- await openPopover("Haiku 4.5");
247
+ await openPopover("Composer 2.5");
167
248
 
168
- fireEvent.click(modelOption("Composer 2.5"));
249
+ fireEvent.click(modelOption("Haiku 4.5"));
169
250
 
170
- expect(onServiceTierChange).not.toHaveBeenCalled();
251
+ expect(onValueChange).toHaveBeenCalledOnce();
252
+ expect(onValueChange).toHaveBeenCalledWith("claude-haiku-4-5");
253
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
254
+ expect(onServiceTierChange).toHaveBeenCalledWith("standard");
171
255
  });
172
256
  });
@@ -92,8 +92,9 @@ export interface ModelInfo {
92
92
  /**
93
93
  * Pricing-variant keys the registry prices for this model (e.g.
94
94
  * `["fast"]`). A priced variant is a *selectable* service tier
95
- * (stigmer/stigmer#357): the tier toggle renders only for models whose
96
- * `serviceTiers` includes it. Empty for models with no variants.
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.
97
98
  *
98
99
  * Distinct from {@link speedTier}, which is a static latency badge,
99
100
  * not a selectable option.
@@ -11,7 +11,7 @@ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v
11
11
  */
12
12
  export type ServiceTierOption = "standard" | "fast";
13
13
 
14
- /** The tier key a model must price for the fast toggle to render. */
14
+ /** The tier key a model must price for the fast-tier switch to render. */
15
15
  export const FAST_SERVICE_TIER = "fast";
16
16
 
17
17
  /**
@@ -1206,7 +1206,7 @@ function normalizeRunConfig(
1206
1206
  if (modelName.trim() !== "") fields.modelName = modelName.trim();
1207
1207
  if (maxCostUsd > 0) fields.maxCostUsd = maxCostUsd;
1208
1208
  if (maxToolRounds > 0) fields.maxToolRounds = maxToolRounds;
1209
- // Only an active fast choice is carried — an untouched toggle stays
1209
+ // Only an active fast choice is carried — an untouched tier stays
1210
1210
  // absent, preserving the unspecified-vs-explicit ledger distinction (#357).
1211
1211
  if (serviceTier === "fast") fields.serviceTier = toProtoServiceTier(serviceTier);
1212
1212
 
@@ -107,11 +107,12 @@ export function ScheduleForm({
107
107
  // the popover without picking a model pins nothing.
108
108
  const [modelName, setModelName] = useState("");
109
109
  const [modelHarness, setModelHarness] = useState<HarnessOption>("cursor");
110
- // Service tier rides the model choice (#357): the ModelSelector renders
111
- // the fast toggle only for models whose registry entry prices a fast
112
- // variant, and resets it when the user switches to one that does not.
113
- // No model pinned = no tier pinned (fast requires a model, and the form's
114
- // reset button clears both).
110
+ // Service tier is scoped by the model choice (#357): the ModelSelector
111
+ // renders the fast-tier switch only while the selected model's registry
112
+ // entry prices a fast variant. An active fast tier survives switches
113
+ // between fast-capable models and resets only when a model without one
114
+ // is picked. No model pinned = no tier pinned (fast requires a model,
115
+ // and the form's reset button clears both).
115
116
  const [serviceTier, setServiceTier] = useState<ServiceTierOption>("standard");
116
117
 
117
118
  const [budgetUsd, setBudgetUsd] = useState("");
@@ -520,7 +521,7 @@ function buildRunConfig(
520
521
  if (Number.isFinite(cost) && cost > 0) config.maxCostUsd = cost;
521
522
  // Carried only when the user actively chose fast AND pinned a model
522
523
  // (fast is a per-model price; the server refuses it without one). An
523
- // untouched toggle stays absent — unspecified-vs-explicit is a
524
+ // untouched tier stays absent — unspecified-vs-explicit is a
524
525
  // load-bearing ledger distinction (#357).
525
526
  if (serviceTier === "fast" && model !== "") {
526
527
  config.serviceTier = toProtoServiceTier(serviceTier);
@@ -476,8 +476,8 @@ describe("ScheduleForm", () => {
476
476
  fireEvent.click(screen.getByRole("button", { name: /Platform default/ }));
477
477
  fireEvent.click(await screen.findByRole("option", { name: /Composer 2\.5/ }));
478
478
 
479
- // The popover closed on selection; reopen it — the toggle renders only
480
- // for the now-selected fast-capable model.
479
+ // The popover closed on selection; reopen it — the options-area switch
480
+ // renders only for the now-selected fast-capable model.
481
481
  fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
482
482
  fireEvent.click(await screen.findByRole("switch", { name: "Fast tier" }));
483
483
 
@@ -6,6 +6,7 @@ import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v
6
6
  import type { AgentShareInput } from "@stigmer/sdk";
7
7
  import { StigmerContext } from "../../context";
8
8
  import {
9
+ draftFromShare,
9
10
  sharingAudienceFromProto,
10
11
  useSaveAgentShare,
11
12
  type AgentShareDraft,
@@ -50,6 +51,7 @@ const FULL_DRAFT: AgentShareDraft = {
50
51
  conversationEnded: "This conversation has ended.",
51
52
  },
52
53
  environmentRefs: [{ org: "acme", slug: "github-org-shared" }],
54
+ runConfig: { modelName: "gpt-5-mini", maxCostUsd: 0.25 },
53
55
  };
54
56
 
55
57
  describe("useSaveAgentShare", () => {
@@ -88,6 +90,7 @@ describe("useSaveAgentShare", () => {
88
90
  expect(input.environmentRefs).toEqual([
89
91
  { org: "acme", slug: "github-org-shared" },
90
92
  ]);
93
+ expect(input.runConfig).toEqual({ modelName: "gpt-5-mini", maxCostUsd: 0.25 });
91
94
  });
92
95
 
93
96
  it("keeps the existing share's identity when editing (a renamed share is never forked)", async () => {
@@ -123,7 +126,15 @@ describe("useSaveAgentShare", () => {
123
126
 
124
127
  await act(() =>
125
128
  result.current.save(
126
- { ...FULL_DRAFT, enabled: false, audience: "org", environmentRefs: [] },
129
+ // An org-audience share carries no environmentRefs or runConfig
130
+ // (both are public-audience-only by the proto CEL rules).
131
+ {
132
+ ...FULL_DRAFT,
133
+ enabled: false,
134
+ audience: "org",
135
+ environmentRefs: [],
136
+ runConfig: undefined,
137
+ },
127
138
  null,
128
139
  ),
129
140
  );
@@ -138,6 +149,49 @@ describe("useSaveAgentShare", () => {
138
149
  expect(input.audience).toBe(AgentShareAudience.org);
139
150
  });
140
151
 
152
+ it("a pause/resume toggle built from draftFromShare carries run_config (never wipes an API-set override)", async () => {
153
+ const apply = vi.fn().mockResolvedValue({});
154
+ const client = createMockStigmer({ apply });
155
+ // A share whose run_config was set via CLI/API — the console list's
156
+ // toggle rebuilds the full spec from draftFromShare, so the override
157
+ // must ride through even though no console surface edits it.
158
+ const share = {
159
+ metadata: { id: "ash_1", org: "acme", slug: "support-agent", name: "Support Agent" },
160
+ spec: {
161
+ enabled: true,
162
+ allowedOrigins: ["https://example.com"],
163
+ environmentRefs: [],
164
+ runConfig: { modelName: "gpt-5-mini", maxCostUsd: 0.25, maxToolRounds: 8 },
165
+ },
166
+ } as unknown as AgentShare;
167
+
168
+ const { result } = renderHook(() => useSaveAgentShare(AGENT), {
169
+ wrapper: wrapper(client),
170
+ });
171
+
172
+ await act(() =>
173
+ result.current.save({ ...draftFromShare(share), enabled: false }, share),
174
+ );
175
+
176
+ const input = apply.mock.calls[0][0] as AgentShareInput;
177
+ expect(input.enabled).toBe(false);
178
+ expect(input.runConfig).toEqual({
179
+ modelName: "gpt-5-mini",
180
+ maxCostUsd: 0.25,
181
+ maxToolRounds: 8,
182
+ serviceTier: undefined,
183
+ });
184
+ });
185
+
186
+ it("draftFromShare leaves run_config undefined when the share carries none", () => {
187
+ expect(draftFromShare(null).runConfig).toBeUndefined();
188
+ const bare = {
189
+ metadata: { id: "ash_2", org: "acme", slug: "bare" },
190
+ spec: { enabled: true },
191
+ } as AgentShare;
192
+ expect(draftFromShare(bare).runConfig).toBeUndefined();
193
+ });
194
+
141
195
  it("maps the audience union to the proto enum, writing public explicitly", async () => {
142
196
  const apply = vi.fn().mockResolvedValue({});
143
197
  const client = createMockStigmer({ apply });
@@ -34,6 +34,7 @@ function makeDraft(overrides?: {
34
34
  allowedOrigins: [],
35
35
  messages: { rateLimited: "", unavailable: "", conversationEnded: "" },
36
36
  environmentRefs: overrides?.environmentRefs ?? [],
37
+ runConfig: undefined,
37
38
  };
38
39
  }
39
40
 
@@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
4
4
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
5
5
  import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
6
6
  import { AgentShareAudience } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/spec_pb";
7
- import type { ResourceRef } from "@stigmer/sdk";
7
+ import type { ResourceRef, RunConfigInput } from "@stigmer/sdk";
8
8
  import { useStigmer } from "../hooks.js";
9
9
  import { toError } from "../internal/toError.js";
10
10
 
@@ -57,6 +57,15 @@ export interface AgentShareDraft {
57
57
  * sessions carry no share linkage in Phase A — decision 011 addendum).
58
58
  */
59
59
  readonly environmentRefs: readonly ResourceRef[];
60
+ /**
61
+ * The owner's per-share execution override (model / cost cap / tool
62
+ * rounds / service tier), merged over the platform guest profile at
63
+ * run time — stigmer/stigmer#360. The console does not edit it (yet):
64
+ * it rides through every save opaquely so a toggle never wipes an
65
+ * override set via the CLI or API. Public-audience only (the same
66
+ * proto CEL rule as environmentRefs, for the same reason).
67
+ */
68
+ readonly runConfig: RunConfigInput | undefined;
60
69
  }
61
70
 
62
71
  /**
@@ -103,6 +112,14 @@ export function draftFromShare(share: AgentShare | null): AgentShareDraft {
103
112
  org: ref.org,
104
113
  slug: ref.slug,
105
114
  })),
115
+ runConfig: spec?.runConfig
116
+ ? {
117
+ modelName: spec.runConfig.modelName,
118
+ maxCostUsd: spec.runConfig.maxCostUsd,
119
+ maxToolRounds: spec.runConfig.maxToolRounds,
120
+ serviceTier: spec.runConfig.serviceTier,
121
+ }
122
+ : undefined,
106
123
  };
107
124
  }
108
125
 
@@ -229,6 +246,7 @@ export function useSaveAgentShare(
229
246
  org: ref.org,
230
247
  slug: ref.slug,
231
248
  })),
249
+ runConfig: draft.runConfig,
232
250
  });
233
251
  } catch (err) {
234
252
  setError(toError(err));