@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
@@ -0,0 +1,172 @@
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
+ * Service-tier toggle gating (stigmer/stigmer#357).
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.
32
+ */
33
+
34
+ const FAST_CAPABLE: ModelInfo = {
35
+ modelId: "composer-2.5",
36
+ provider: "cursor",
37
+ displayName: "Composer 2.5",
38
+ shortDescription: "Fast agentic model",
39
+ speedTier: "fastest",
40
+ costTier: "economy",
41
+ harness: "cursor",
42
+ featured: true,
43
+ serviceTiers: ["fast"],
44
+ };
45
+
46
+ const NO_FAST_TIER: ModelInfo = {
47
+ modelId: "claude-haiku-4-5",
48
+ provider: "anthropic",
49
+ displayName: "Haiku 4.5",
50
+ shortDescription: "Small fast model",
51
+ speedTier: "fastest",
52
+ costTier: "economy",
53
+ harness: "cursor",
54
+ featured: true,
55
+ serviceTiers: [],
56
+ };
57
+
58
+ function renderSelector(
59
+ props?: Partial<React.ComponentProps<typeof ModelSelector>>,
60
+ ) {
61
+ const onValueChange = vi.fn();
62
+ const onServiceTierChange = vi.fn();
63
+
64
+ const result = render(
65
+ <ModelRegistryContext.Provider
66
+ value={{
67
+ models: [FAST_CAPABLE, NO_FAST_TIER],
68
+ isLoading: false,
69
+ error: null,
70
+ refetch: vi.fn(),
71
+ }}
72
+ >
73
+ <ModelSelector
74
+ value="composer-2.5"
75
+ onValueChange={onValueChange}
76
+ harness="cursor"
77
+ serviceTier="standard"
78
+ onServiceTierChange={onServiceTierChange}
79
+ {...props}
80
+ />
81
+ </ModelRegistryContext.Provider>,
82
+ );
83
+
84
+ return { ...result, onValueChange, onServiceTierChange };
85
+ }
86
+
87
+ /**
88
+ * Open the popover via its trigger and wait for the portaled popup to
89
+ * mount (Base UI portals render asynchronously in jsdom). The search
90
+ * input is the popup's always-present landmark.
91
+ */
92
+ async function openPopover(triggerLabel: string) {
93
+ fireEvent.click(screen.getByRole("button", { name: new RegExp(triggerLabel) }));
94
+ await screen.findByPlaceholderText("Search models…");
95
+ }
96
+
97
+ /** A model row inside the popup list (never matches the trigger). */
98
+ function modelOption(displayName: string): HTMLElement {
99
+ const option = screen
100
+ .getAllByText(displayName)
101
+ .map((el) => el.closest<HTMLElement>("[data-model-option]"))
102
+ .find((el) => el != null);
103
+ if (!option) throw new Error(`No popup option for "${displayName}"`);
104
+ return option;
105
+ }
106
+
107
+ afterEach(cleanup);
108
+
109
+ describe("ModelSelector — service tier toggle", () => {
110
+ it("renders the toggle for a fast-capable model", async () => {
111
+ renderSelector();
112
+ await openPopover("Composer 2.5");
113
+
114
+ const toggle = screen.getByRole("switch", { name: "Fast tier" });
115
+ expect(toggle.getAttribute("aria-checked")).toBe("false");
116
+ });
117
+
118
+ it("does not render the toggle for a model without a fast tier", async () => {
119
+ renderSelector({ value: "claude-haiku-4-5" });
120
+ await openPopover("Haiku 4.5");
121
+
122
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
123
+ });
124
+
125
+ it("does not render the toggle when the consumer did not opt in", async () => {
126
+ renderSelector({ onServiceTierChange: undefined });
127
+ await openPopover("Composer 2.5");
128
+
129
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
130
+ });
131
+
132
+ it("toggling fires onServiceTierChange with the flipped tier", async () => {
133
+ const { onServiceTierChange } = renderSelector();
134
+ await openPopover("Composer 2.5");
135
+
136
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
137
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
138
+ expect(onServiceTierChange).toHaveBeenCalledWith("fast");
139
+ });
140
+
141
+ it("shows the Fast badge on the trigger when the fast tier is active", () => {
142
+ renderSelector({ serviceTier: "fast" });
143
+
144
+ expect(screen.getByText("Fast")).toBeTruthy();
145
+ });
146
+
147
+ it("resets an active fast tier when selecting a model without one", async () => {
148
+ const { onValueChange, onServiceTierChange } = renderSelector({
149
+ serviceTier: "fast",
150
+ });
151
+ await openPopover("Composer 2.5");
152
+
153
+ fireEvent.click(modelOption("Haiku 4.5"));
154
+
155
+ expect(onValueChange).toHaveBeenCalledOnce();
156
+ expect(onValueChange).toHaveBeenCalledWith("claude-haiku-4-5");
157
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
158
+ expect(onServiceTierChange).toHaveBeenCalledWith("standard");
159
+ });
160
+
161
+ it("keeps an active fast tier when selecting another fast-capable model", async () => {
162
+ const { onServiceTierChange } = renderSelector({
163
+ serviceTier: "fast",
164
+ value: "claude-haiku-4-5",
165
+ });
166
+ await openPopover("Haiku 4.5");
167
+
168
+ fireEvent.click(modelOption("Composer 2.5"));
169
+
170
+ expect(onServiceTierChange).not.toHaveBeenCalled();
171
+ });
172
+ });
@@ -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,16 @@ 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 tier toggle renders only for models whose
96
+ * `serviceTiers` includes it. Empty for models with no variants.
97
+ *
98
+ * Distinct from {@link speedTier}, which is a static latency badge,
99
+ * not a selectable option.
100
+ */
101
+ readonly serviceTiers: readonly string[];
92
102
  }
93
103
 
94
104
  /**
@@ -153,6 +163,8 @@ interface RegistryJsonEntry {
153
163
  cacheWritePricePerMillion: number;
154
164
  cacheReadPricePerMillion: number;
155
165
  };
166
+ /** Variant-key → variant pricing block; only the key set matters here. */
167
+ pricingVariants?: Record<string, unknown>;
156
168
  $comment?: string;
157
169
  }
158
170
 
@@ -192,6 +204,10 @@ export function parseRegistryJson(data: unknown): ModelInfo[] {
192
204
  costTier: m.costTier as CostTier,
193
205
  harness: m.harness as HarnessOption,
194
206
  featured: m.featured ?? false,
207
+ serviceTiers:
208
+ m.pricingVariants && typeof m.pricingVariants === "object"
209
+ ? Object.keys(m.pricingVariants).sort()
210
+ : [],
195
211
  }));
196
212
  }
197
213
 
@@ -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 toggle 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
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from "react";
13
13
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
14
14
  import { useStigmer } from "../hooks.js";
15
+ import { useFetchCache } from "../internal/FetchCacheProvider.js";
15
16
 
16
17
  /** Value exposed by {@link OrgProvider} via {@link useOrg}. */
17
18
  export interface OrgContextValue {
@@ -63,19 +64,27 @@ function persistSlug(slug: string): void {
63
64
  * organization selection, and persists the choice to `localStorage`
64
65
  * under the key `stigmer:activeOrgSlug`.
65
66
  *
66
- * Must be rendered inside a {@link StigmerProvider}.
67
+ * Must be rendered inside a {@link StigmerProvider}. Mount it BELOW
68
+ * `FetchCacheProvider` (when one is used): switching the active org clears
69
+ * the nearest fetch cache, so view state cached under the previous org
70
+ * context — session and execution entries are keyed by id, not org — can
71
+ * never bleed into the new one. Navigation on switch stays the host's
72
+ * responsibility via `OrgSwitcher`'s `onOrgChanged` callback.
67
73
  *
68
74
  * @example
69
75
  * ```tsx
70
76
  * <StigmerProvider client={client}>
71
- * <OrgProvider>
72
- * <App />
73
- * </OrgProvider>
77
+ * <FetchCacheProvider>
78
+ * <OrgProvider>
79
+ * <App />
80
+ * </OrgProvider>
81
+ * </FetchCacheProvider>
74
82
  * </StigmerProvider>
75
83
  * ```
76
84
  */
77
85
  export function OrgProvider({ children }: { children: ReactNode }) {
78
86
  const stigmer = useStigmer();
87
+ const fetchCache = useFetchCache();
79
88
  const [orgs, setOrgs] = useState<Organization[]>([]);
80
89
  const [activeOrg, setActiveOrgState] = useState<Organization | null>(null);
81
90
  const [isLoading, setIsLoading] = useState(true);
@@ -140,6 +149,22 @@ export function OrgProvider({ children }: { children: ReactNode }) {
140
149
  }
141
150
  }, []);
142
151
 
152
+ // Org-switch invariant: view state cached under one org context must not
153
+ // survive into another. Covers every path that changes the active org
154
+ // (explicit switch, post-create refresh) while skipping the initial
155
+ // restore (previous slug is null). Cache keys for sessions/executions are
156
+ // id-scoped, not org-scoped, so a TTL'd entry would otherwise outlive the
157
+ // switch. No-op when no FetchCacheProvider is mounted.
158
+ const previousSlugRef = useRef<string | null>(null);
159
+ const activeSlug = activeOrg?.metadata?.slug ?? null;
160
+ useEffect(() => {
161
+ const previous = previousSlugRef.current;
162
+ previousSlugRef.current = activeSlug;
163
+ if (previous !== null && activeSlug !== null && previous !== activeSlug) {
164
+ fetchCache?.clear();
165
+ }
166
+ }, [activeSlug, fetchCache]);
167
+
143
168
  const value = useMemo<OrgContextValue>(
144
169
  () => ({
145
170
  orgs,
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
5
+ import { StigmerContext } from "../../context";
6
+ import { FetchCache } from "../../internal/fetch-cache";
7
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
8
+ import { OrgProvider, useOrg } from "../OrgProvider";
9
+
10
+ const acme = { metadata: { id: "acme", slug: "acme", name: "Acme" } } as Organization;
11
+ const globex = { metadata: { id: "globex", slug: "globex", name: "Globex" } } as Organization;
12
+
13
+ function createMockStigmer(orgs: Organization[]) {
14
+ return {
15
+ organization: {
16
+ findMyOrganizations: vi.fn().mockResolvedValue({ entries: orgs }),
17
+ },
18
+ } as never;
19
+ }
20
+
21
+ function wrapper(client: unknown, cache: FetchCache | null) {
22
+ return function Wrapper({ children }: { children: ReactNode }) {
23
+ return (
24
+ <StigmerContext.Provider value={client as never}>
25
+ <FetchCacheContext.Provider value={cache}>
26
+ <OrgProvider>{children}</OrgProvider>
27
+ </FetchCacheContext.Provider>
28
+ </StigmerContext.Provider>
29
+ );
30
+ };
31
+ }
32
+
33
+ describe("OrgProvider — org-switch cache invalidation", () => {
34
+ beforeEach(() => {
35
+ localStorage.clear();
36
+ });
37
+
38
+ it("clears the fetch cache when the active org changes", async () => {
39
+ const cache = new FetchCache();
40
+ cache.set("session:ses_1", { stale: "from previous org context" });
41
+
42
+ const { result } = renderHook(() => useOrg(), {
43
+ wrapper: wrapper(createMockStigmer([acme, globex]), cache),
44
+ });
45
+
46
+ await waitFor(() => expect(result.current.activeOrg).not.toBeNull());
47
+
48
+ // Initial restore must NOT clear — only a genuine change of context does.
49
+ // Session/execution cache keys are id-scoped (not org-scoped), so without
50
+ // the clear a switched-away org's view state would survive its 5-min TTL.
51
+ expect(cache.has("session:ses_1")).toBe(true);
52
+
53
+ act(() => result.current.setActiveOrg(globex));
54
+
55
+ await waitFor(() => expect(cache.has("session:ses_1")).toBe(false));
56
+ expect(result.current.activeOrg?.metadata?.slug).toBe("globex");
57
+ });
58
+
59
+ it("does not clear when re-selecting the already-active org", async () => {
60
+ const cache = new FetchCache();
61
+
62
+ const { result } = renderHook(() => useOrg(), {
63
+ wrapper: wrapper(createMockStigmer([acme, globex]), cache),
64
+ });
65
+
66
+ await waitFor(() => expect(result.current.activeOrg).not.toBeNull());
67
+ const active = result.current.activeOrg!;
68
+
69
+ cache.set("session:ses_1", { fresh: true });
70
+ act(() => result.current.setActiveOrg(active));
71
+
72
+ await new Promise((resolve) => setTimeout(resolve, 0));
73
+ expect(cache.has("session:ses_1")).toBe(true);
74
+ });
75
+
76
+ it("works without a FetchCacheProvider (no-op, no crash)", async () => {
77
+ const { result } = renderHook(() => useOrg(), {
78
+ wrapper: wrapper(createMockStigmer([acme, globex]), null),
79
+ });
80
+
81
+ await waitFor(() => expect(result.current.activeOrg).not.toBeNull());
82
+
83
+ act(() => result.current.setActiveOrg(globex));
84
+
85
+ await waitFor(() =>
86
+ expect(result.current.activeOrg?.metadata?.slug).toBe("globex"),
87
+ );
88
+ });
89
+ });
@@ -6,7 +6,12 @@ import { toError } from "../internal/toError.js";
6
6
  import { toast } from "../feedback/toast.js";
7
7
 
8
8
  /** Resource types that support deletion from detail pages. */
9
- export type DeletableResourceKind = "agent" | "skill" | "mcpServer" | "workflow";
9
+ export type DeletableResourceKind =
10
+ | "agent"
11
+ | "skill"
12
+ | "mcpServer"
13
+ | "workflow"
14
+ | "schedule";
10
15
 
11
16
  export interface UseDeleteResourceReturn {
12
17
  /**
@@ -74,6 +79,9 @@ export function useDeleteResource(
74
79
  case "workflow":
75
80
  await stigmer.workflow.delete(resourceId);
76
81
  break;
82
+ case "schedule":
83
+ await stigmer.schedule.delete(resourceId);
84
+ break;
77
85
  }
78
86
  toast.success(
79
87
  resourceName
@@ -106,5 +114,7 @@ function kindLabel(kind: DeletableResourceKind): string {
106
114
  return "MCP server";
107
115
  case "workflow":
108
116
  return "workflow";
117
+ case "schedule":
118
+ return "schedule";
109
119
  }
110
120
  }
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, renderHook, screen, waitFor, cleanup } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { ListResult } from "@stigmer/sdk";
5
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
6
+ import { ResourceWorkbench } from "../components/ResourceWorkbench";
7
+ import { useResourceCollection } from "../hooks/useResourceCollection";
8
+
9
+ afterEach(cleanup);
10
+
11
+ function Wrapper({ children }: { children: ReactNode }) {
12
+ return (
13
+ <FetchCacheContext.Provider value={null}>
14
+ {children}
15
+ </FetchCacheContext.Provider>
16
+ );
17
+ }
18
+
19
+ /** A row shaped like a resource proto: the id lives under metadata. */
20
+ interface ProtoLikeRow {
21
+ readonly metadata: { readonly id: string; readonly slug: string };
22
+ }
23
+
24
+ function listFnReturning(entries: unknown[], totalCount = entries.length) {
25
+ return vi.fn(
26
+ async (): Promise<ListResult> => ({
27
+ entries: entries as ListResult["entries"],
28
+ totalCount,
29
+ totalPages: 1,
30
+ }),
31
+ );
32
+ }
33
+
34
+ describe("ResourceWorkbench searchable prop", () => {
35
+ it("renders the search input by default", async () => {
36
+ render(
37
+ <ResourceWorkbench listFn={listFnReturning([])} org="acme" />,
38
+ { wrapper: Wrapper },
39
+ );
40
+
41
+ await waitFor(() =>
42
+ expect(screen.getByRole("textbox", { name: "Search…" })).toBeTruthy(),
43
+ );
44
+ });
45
+
46
+ it("hides the search input when searchable is false", async () => {
47
+ render(
48
+ <ResourceWorkbench
49
+ listFn={listFnReturning([])}
50
+ org="acme"
51
+ searchable={false}
52
+ />,
53
+ { wrapper: Wrapper },
54
+ );
55
+
56
+ await waitFor(() => expect(screen.getByText("No resources found")).toBeTruthy());
57
+ expect(screen.queryByRole("textbox")).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("useResourceCollection row identity", () => {
62
+ const protoRows: ProtoLikeRow[] = [
63
+ { metadata: { id: "sch_a", slug: "a" } },
64
+ { metadata: { id: "sch_b", slug: "b" } },
65
+ ];
66
+
67
+ const columns = [
68
+ {
69
+ id: "slug",
70
+ header: "Slug",
71
+ cell: (row: ProtoLikeRow) => row.metadata.slug,
72
+ },
73
+ ];
74
+
75
+ it("uses getItemId for rows without a top-level id", async () => {
76
+ // Hoisted: a listFn re-created per render would retrigger useFetch.
77
+ const listFn = listFnReturning(protoRows);
78
+ const { result } = renderHook(
79
+ () =>
80
+ useResourceCollection<ProtoLikeRow>({
81
+ listFn,
82
+ org: "acme",
83
+ columns,
84
+ getItemId: (row) => row.metadata.id,
85
+ }),
86
+ { wrapper: Wrapper },
87
+ );
88
+
89
+ await waitFor(() => expect(result.current.items).toHaveLength(2));
90
+ const rowIds = result.current.table!.getRowModel().rows.map((r) => r.id);
91
+ expect(rowIds).toEqual(["sch_a", "sch_b"]);
92
+ });
93
+
94
+ it("falls back to SearchResult.id, then the row index", async () => {
95
+ const mixed = [{ id: "res_1" }, { name: "no-id" }];
96
+ const listFn = listFnReturning(mixed);
97
+ const fallbackColumns = [{ id: "c", header: "C", cell: () => null }];
98
+ const { result } = renderHook(
99
+ () =>
100
+ useResourceCollection({
101
+ listFn,
102
+ org: "acme",
103
+ columns: fallbackColumns,
104
+ }),
105
+ { wrapper: Wrapper },
106
+ );
107
+
108
+ await waitFor(() => expect(result.current.items).toHaveLength(2));
109
+ const rowIds = result.current.table!.getRowModel().rows.map((r) => r.id);
110
+ expect(rowIds).toEqual(["res_1", "1"]);
111
+ });
112
+ });
@@ -155,6 +155,15 @@ export interface ResourceWorkbenchProps<TData = SearchResult> {
155
155
 
156
156
  // --- Layout ------------------------------------------------------------
157
157
 
158
+ /**
159
+ * Whether the toolbar renders the text-search input. Set `false` for
160
+ * kinds whose list backend has no text search (e.g. schedules, which
161
+ * use the direct query instead of the search service) — a search box
162
+ * that silently matches nothing is worse than none (DD-006). Opt-in
163
+ * per DD-011: existing consumers keep the search box.
164
+ * @default true
165
+ */
166
+ readonly searchable?: boolean;
158
167
  /** Search input placeholder text. @default "Search\u2026" */
159
168
  readonly searchPlaceholder?: string;
160
169
  /** Accessible label for the workbench region. @default "Resource workbench" */
@@ -228,6 +237,7 @@ export function ResourceWorkbench<TData = SearchResult>({
228
237
  emptyAction,
229
238
  onRetry,
230
239
  headerAction,
240
+ searchable = true,
231
241
  searchPlaceholder = "Search\u2026",
232
242
  "aria-label": ariaLabel = "Resource workbench",
233
243
  className,
@@ -267,6 +277,7 @@ export function ResourceWorkbench<TData = SearchResult>({
267
277
  onSortChange: filtersHook.setSort,
268
278
  columns: columns as WorkbenchColumnDef<TData>[],
269
279
  enableSelection,
280
+ getItemId,
270
281
  refetchToken,
271
282
  });
272
283
 
@@ -305,21 +316,27 @@ export function ResourceWorkbench<TData = SearchResult>({
305
316
  >
306
317
  {/* --- Toolbar: search + filters + view switcher --- */}
307
318
  <div className="flex flex-wrap items-center gap-2">
308
- <div className="relative flex-1">
309
- <SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground-subtle" />
310
- <input
311
- type="text"
312
- value={filtersHook.query}
313
- onChange={(e) => filtersHook.setQuery(e.target.value)}
314
- placeholder={searchPlaceholder}
315
- aria-label={searchPlaceholder}
316
- className={cn(
317
- "w-full rounded-md border border-input bg-background py-1.5 pl-8 pr-3 text-sm",
318
- "placeholder:text-muted-foreground-subtle",
319
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
320
- )}
321
- />
322
- </div>
319
+ {searchable ? (
320
+ <div className="relative flex-1">
321
+ <SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground-subtle" />
322
+ <input
323
+ type="text"
324
+ value={filtersHook.query}
325
+ onChange={(e) => filtersHook.setQuery(e.target.value)}
326
+ placeholder={searchPlaceholder}
327
+ aria-label={searchPlaceholder}
328
+ className={cn(
329
+ "w-full rounded-md border border-input bg-background py-1.5 pl-8 pr-3 text-sm",
330
+ "placeholder:text-muted-foreground-subtle",
331
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
332
+ )}
333
+ />
334
+ </div>
335
+ ) : (
336
+ // Spacer keeps the scope toggle / view switcher / header action
337
+ // right-aligned, matching the searchable layout.
338
+ <div className="flex-1" aria-hidden="true" />
339
+ )}
323
340
  {onScopeChange && (
324
341
  <ScopeToggle value={controlledScope} onChange={onScopeChange} />
325
342
  )}