@stigmer/react 0.2.3 → 0.3.1

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 (118) hide show
  1. package/composer/ComposerToolbar.d.ts +5 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +6 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +17 -1
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +10 -3
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/execution/MessageEntry.d.ts +3 -1
  10. package/execution/MessageEntry.d.ts.map +1 -1
  11. package/execution/MessageEntry.js +30 -1
  12. package/execution/MessageEntry.js.map +1 -1
  13. package/index.d.ts +3 -3
  14. package/index.d.ts.map +1 -1
  15. package/index.js +2 -2
  16. package/index.js.map +1 -1
  17. package/models/HarnessSelector.d.ts +41 -0
  18. package/models/HarnessSelector.d.ts.map +1 -0
  19. package/models/HarnessSelector.js +74 -0
  20. package/models/HarnessSelector.js.map +1 -0
  21. package/models/ModelSelector.d.ts +26 -16
  22. package/models/ModelSelector.d.ts.map +1 -1
  23. package/models/ModelSelector.js +128 -48
  24. package/models/ModelSelector.js.map +1 -1
  25. package/models/__tests__/HarnessSelector.test.d.ts +2 -0
  26. package/models/__tests__/HarnessSelector.test.d.ts.map +1 -0
  27. package/models/__tests__/HarnessSelector.test.js +160 -0
  28. package/models/__tests__/HarnessSelector.test.js.map +1 -0
  29. package/models/__tests__/harness.test.d.ts +2 -0
  30. package/models/__tests__/harness.test.d.ts.map +1 -0
  31. package/models/__tests__/harness.test.js +50 -0
  32. package/models/__tests__/harness.test.js.map +1 -0
  33. package/models/__tests__/useModelRegistry.test.d.ts +2 -0
  34. package/models/__tests__/useModelRegistry.test.d.ts.map +1 -0
  35. package/models/__tests__/useModelRegistry.test.js +148 -0
  36. package/models/__tests__/useModelRegistry.test.js.map +1 -0
  37. package/models/harness.d.ts +21 -0
  38. package/models/harness.d.ts.map +1 -0
  39. package/models/harness.js +34 -0
  40. package/models/harness.js.map +1 -0
  41. package/models/index.d.ts +7 -2
  42. package/models/index.d.ts.map +1 -1
  43. package/models/index.js +3 -1
  44. package/models/index.js.map +1 -1
  45. package/models/registry.d.ts +53 -13
  46. package/models/registry.d.ts.map +1 -1
  47. package/models/registry.js +51 -40
  48. package/models/registry.js.map +1 -1
  49. package/models/useModelRegistry.d.ts +39 -19
  50. package/models/useModelRegistry.d.ts.map +1 -1
  51. package/models/useModelRegistry.js +45 -23
  52. package/models/useModelRegistry.js.map +1 -1
  53. package/package.json +4 -4
  54. package/runner/RunnerListPanel.js +2 -1
  55. package/runner/RunnerListPanel.js.map +1 -1
  56. package/runner/__tests__/phase.test.js +6 -2
  57. package/runner/__tests__/phase.test.js.map +1 -1
  58. package/runner/phase.d.ts +9 -7
  59. package/runner/phase.d.ts.map +1 -1
  60. package/runner/phase.js +18 -12
  61. package/runner/phase.js.map +1 -1
  62. package/session/__tests__/useCreateSession.test.d.ts +2 -0
  63. package/session/__tests__/useCreateSession.test.d.ts.map +1 -0
  64. package/session/__tests__/useCreateSession.test.js +232 -0
  65. package/session/__tests__/useCreateSession.test.js.map +1 -0
  66. package/session/__tests__/useNewSessionFlow.test.d.ts +2 -0
  67. package/session/__tests__/useNewSessionFlow.test.d.ts.map +1 -0
  68. package/session/__tests__/useNewSessionFlow.test.js +199 -0
  69. package/session/__tests__/useNewSessionFlow.test.js.map +1 -0
  70. package/session/__tests__/useSessionConversation.test.js +37 -0
  71. package/session/__tests__/useSessionConversation.test.js.map +1 -1
  72. package/session/index.d.ts +1 -1
  73. package/session/index.d.ts.map +1 -1
  74. package/session/useCreateSession.d.ts +8 -0
  75. package/session/useCreateSession.d.ts.map +1 -1
  76. package/session/useCreateSession.js +2 -0
  77. package/session/useCreateSession.js.map +1 -1
  78. package/session/useNewSessionFlow.d.ts +6 -1
  79. package/session/useNewSessionFlow.d.ts.map +1 -1
  80. package/session/useNewSessionFlow.js +34 -8
  81. package/session/useNewSessionFlow.js.map +1 -1
  82. package/session/usePersistedModel.d.ts +16 -1
  83. package/session/usePersistedModel.d.ts.map +1 -1
  84. package/session/usePersistedModel.js +15 -6
  85. package/session/usePersistedModel.js.map +1 -1
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +6 -1
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/session/useSessionPageFlow.d.ts +11 -0
  90. package/session/useSessionPageFlow.d.ts.map +1 -1
  91. package/session/useSessionPageFlow.js +11 -2
  92. package/session/useSessionPageFlow.js.map +1 -1
  93. package/src/composer/ComposerToolbar.tsx +24 -1
  94. package/src/composer/SessionComposer.tsx +35 -1
  95. package/src/execution/MessageEntry.tsx +134 -1
  96. package/src/index.ts +15 -1
  97. package/src/models/HarnessSelector.tsx +130 -0
  98. package/src/models/ModelSelector.tsx +285 -81
  99. package/src/models/__tests__/HarnessSelector.test.tsx +190 -0
  100. package/src/models/__tests__/harness.test.ts +66 -0
  101. package/src/models/__tests__/useModelRegistry.test.tsx +209 -0
  102. package/src/models/harness.ts +45 -0
  103. package/src/models/index.ts +7 -2
  104. package/src/models/registry.ts +122 -50
  105. package/src/models/useModelRegistry.ts +74 -24
  106. package/src/runner/RunnerListPanel.tsx +13 -5
  107. package/src/runner/__tests__/phase.test.ts +6 -2
  108. package/src/runner/phase.ts +18 -12
  109. package/src/session/__tests__/useCreateSession.test.tsx +296 -0
  110. package/src/session/__tests__/useNewSessionFlow.test.tsx +258 -0
  111. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  112. package/src/session/index.ts +1 -1
  113. package/src/session/useCreateSession.ts +9 -0
  114. package/src/session/useNewSessionFlow.ts +46 -9
  115. package/src/session/usePersistedModel.ts +30 -6
  116. package/src/session/useSessionConversation.ts +6 -1
  117. package/src/session/useSessionPageFlow.ts +26 -2
  118. package/styles.css +1 -1
@@ -1,16 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { Select } from "@base-ui/react/select";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Popover } from "@base-ui/react/popover";
5
+ import { cn } from "@stigmer/theme";
4
6
  import { useModelRegistry } from "./useModelRegistry";
5
- import type { Provider } from "./registry";
7
+ import { modelKey, parseModelKey, type ModelInfo, type CostTier } from "./registry";
8
+ import { HARNESS_LABELS, type HarnessOption } from "./harness";
6
9
 
7
- const PROVIDER_LABELS: Record<Provider, string> = {
8
- anthropic: "Anthropic",
9
- openai: "OpenAI",
10
- ollama: "Ollama",
11
- };
12
-
13
- const COST_TIER_INDICATOR: Record<string, string> = {
10
+ const COST_TIER_LABEL: Record<CostTier, string> = {
14
11
  economy: "$",
15
12
  standard: "$$",
16
13
  premium: "$$$",
@@ -18,10 +15,20 @@ const COST_TIER_INDICATOR: Record<string, string> = {
18
15
 
19
16
  /** Props for {@link ModelSelector}. */
20
17
  export interface ModelSelectorProps {
21
- /** Currently selected model ID. Falls back to {@link DEFAULT_MODEL_ID} when omitted. */
18
+ /** Currently selected compound key (`"native/claude-sonnet-4.6"`) or plain `modelId`. */
22
19
  readonly value?: string;
23
- /** Called when the user picks a different model. Receives the new `modelId`. */
20
+ /** Called when the user picks a different model. Receives the `modelId`. */
24
21
  readonly onValueChange: (modelId: string) => void;
22
+ /**
23
+ * When provided, restricts the catalog to a single harness (backward compat).
24
+ * When omitted, shows the unified picker with models from both engines.
25
+ */
26
+ readonly harness?: HarnessOption;
27
+ /**
28
+ * Fires when the selected model belongs to a different harness than
29
+ * the previous selection. Only relevant in unified mode (no `harness` prop).
30
+ */
31
+ readonly onHarnessResolved?: (harness: HarnessOption) => void;
25
32
  /** Additional CSS class names for the trigger button. */
26
33
  readonly className?: string;
27
34
  /** When true, disables the selector. */
@@ -29,107 +36,286 @@ export interface ModelSelectorProps {
29
36
  }
30
37
 
31
38
  /**
32
- * Theme-able model picker built on `@base-ui/react` Select for
33
- * accessible keyboard navigation and ARIA.
39
+ * Cursor-style model picker: a flat searchable list inside a popover.
34
40
  *
35
- * Consumes {@link useModelRegistry} internally. Groups models by
36
- * provider and shows a subtle cost-tier indicator.
41
+ * Shows a curated list of featured models by default. The user can
42
+ * expand via "Show All Models" or type to search the full catalog.
37
43
  *
38
- * All visual properties flow through `--stgm-*` tokens no
39
- * hardcoded colors or sizes.
44
+ * Each model row shows the display name, an engine tag
45
+ * ("Stigmer" / "Cursor"), and a cost-tier indicator.
40
46
  *
41
- * Platform builders who need different rendering use
42
- * `useModelRegistry()` directly.
47
+ * In unified mode (no `harness` prop), selecting a model implicitly
48
+ * resolves the harness via {@link ModelSelectorProps.onHarnessResolved}.
43
49
  *
44
50
  * @example
45
51
  * ```tsx
46
- * function ComposerHeader() {
47
- * const [modelId, setModelId] = useState<string>();
48
- *
49
- * return <ModelSelector value={modelId} onValueChange={setModelId} />;
50
- * }
52
+ * <ModelSelector
53
+ * value={selectedModelId}
54
+ * onValueChange={setSelectedModelId}
55
+ * onHarnessResolved={setHarness}
56
+ * />
51
57
  * ```
52
58
  */
53
59
  export function ModelSelector({
54
60
  value,
55
61
  onValueChange,
62
+ harness,
63
+ onHarnessResolved,
56
64
  className,
57
65
  disabled,
58
66
  }: ModelSelectorProps) {
59
- const { byProvider, defaultModel, providers } = useModelRegistry();
67
+ const isUnified = harness === undefined;
68
+ const { models, featured, defaultModel, getModel, getByKey } = useModelRegistry(
69
+ isUnified ? undefined : { harness },
70
+ );
71
+
72
+ const [open, setOpen] = useState(false);
73
+ const [searchQuery, setSearchQuery] = useState("");
74
+ const [showAll, setShowAll] = useState(false);
75
+ const [highlightIdx, setHighlightIdx] = useState(-1);
76
+
77
+ const searchRef = useRef<HTMLInputElement>(null);
78
+ const listRef = useRef<HTMLDivElement>(null);
79
+
80
+ const resolveSelected = useCallback((): ModelInfo | undefined => {
81
+ if (!value) return undefined;
82
+ if (isUnified) {
83
+ const byKey = getByKey(value);
84
+ if (byKey) return byKey;
85
+ }
86
+ return getModel(value);
87
+ }, [value, isUnified, getByKey, getModel]);
88
+
89
+ const selectedModel = resolveSelected() ?? defaultModel;
90
+
91
+ const isSearching = searchQuery.length > 0;
92
+ const lowerQuery = searchQuery.toLowerCase();
93
+
94
+ const visibleModels: readonly ModelInfo[] = useMemo(() => {
95
+ if (isSearching) {
96
+ return models.filter((m) =>
97
+ m.displayName.toLowerCase().includes(lowerQuery)
98
+ || m.modelId.toLowerCase().includes(lowerQuery)
99
+ || HARNESS_LABELS[m.harness].toLowerCase().includes(lowerQuery),
100
+ );
101
+ }
102
+ if (showAll) return models;
103
+ return featured.length > 0 ? featured : models;
104
+ }, [models, featured, isSearching, showAll, lowerQuery]);
105
+
106
+ useEffect(() => {
107
+ setHighlightIdx(-1);
108
+ }, [visibleModels]);
109
+
110
+ useEffect(() => {
111
+ if (!open) {
112
+ setSearchQuery("");
113
+ setShowAll(false);
114
+ setHighlightIdx(-1);
115
+ }
116
+ }, [open]);
117
+
118
+ useEffect(() => {
119
+ if (open) {
120
+ requestAnimationFrame(() => searchRef.current?.focus());
121
+ }
122
+ }, [open]);
123
+
124
+ const selectModel = useCallback(
125
+ (model: ModelInfo) => {
126
+ const key = isUnified ? modelKey(model.harness, model.modelId) : model.modelId;
127
+ onValueChange(key);
128
+ if (isUnified && onHarnessResolved && model.harness !== selectedModel?.harness) {
129
+ onHarnessResolved(model.harness);
130
+ }
131
+ setOpen(false);
132
+ },
133
+ [isUnified, onValueChange, onHarnessResolved, selectedModel],
134
+ );
135
+
136
+ const scrollHighlightIntoView = useCallback((idx: number) => {
137
+ const container = listRef.current;
138
+ if (!container) return;
139
+ const items = container.querySelectorAll<HTMLElement>("[data-model-option]");
140
+ items[idx]?.scrollIntoView({ block: "nearest" });
141
+ }, []);
142
+
143
+ const handleKeyDown = useCallback(
144
+ (e: React.KeyboardEvent) => {
145
+ const len = visibleModels.length;
146
+ if (len === 0) return;
147
+
148
+ switch (e.key) {
149
+ case "ArrowDown": {
150
+ e.preventDefault();
151
+ const next = highlightIdx < len - 1 ? highlightIdx + 1 : 0;
152
+ setHighlightIdx(next);
153
+ scrollHighlightIntoView(next);
154
+ break;
155
+ }
156
+ case "ArrowUp": {
157
+ e.preventDefault();
158
+ const prev = highlightIdx > 0 ? highlightIdx - 1 : len - 1;
159
+ setHighlightIdx(prev);
160
+ scrollHighlightIntoView(prev);
161
+ break;
162
+ }
163
+ case "Enter": {
164
+ e.preventDefault();
165
+ const target = highlightIdx >= 0 ? visibleModels[highlightIdx] : visibleModels[0];
166
+ if (target) selectModel(target);
167
+ break;
168
+ }
169
+ case "Escape":
170
+ e.preventDefault();
171
+ setOpen(false);
172
+ break;
173
+ }
174
+ },
175
+ [visibleModels, highlightIdx, selectModel, scrollHighlightIntoView],
176
+ );
177
+
178
+ const showShowAllButton = !isSearching && !showAll && featured.length > 0 && featured.length < models.length;
179
+
180
+ const triggerLabel = selectedModel.displayName;
181
+ const triggerHarness = isUnified ? HARNESS_LABELS[selectedModel.harness] : undefined;
182
+ const triggerCost = COST_TIER_LABEL[selectedModel.costTier];
60
183
 
61
184
  return (
62
- <Select.Root
63
- value={value ?? defaultModel.modelId}
64
- onValueChange={(v) => { if (v !== null) onValueChange(v); }}
65
- disabled={disabled}
66
- >
67
- <Select.Trigger
68
- className={[
185
+ <Popover.Root open={open} onOpenChange={setOpen}>
186
+ <Popover.Trigger
187
+ disabled={disabled}
188
+ className={cn(
69
189
  "inline-flex items-center gap-1.5 rounded-md border border-border",
70
190
  "bg-background px-2.5 py-1.5 text-xs text-foreground",
71
191
  "hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
72
192
  "disabled:pointer-events-none disabled:opacity-50",
73
- "transition-colors max-w-[14rem] max-sm:max-w-[8rem]",
193
+ "transition-colors max-w-[18rem] max-sm:max-w-[10rem]",
74
194
  className,
75
- ]
76
- .filter(Boolean)
77
- .join(" ")}
195
+ )}
78
196
  >
79
- <Select.Value placeholder="Select model" className="truncate" />
80
- <Select.Icon className="text-muted-foreground">
81
- <ChevronIcon />
82
- </Select.Icon>
83
- </Select.Trigger>
84
-
85
- <Select.Portal>
86
- <Select.Positioner sideOffset={4}>
87
- <Select.Popup
88
- className={[
89
- "z-popover max-h-72 min-w-[var(--anchor-width)] overflow-auto",
90
- "rounded-lg border border-border bg-popover p-1 shadow-md",
197
+ <span className="truncate">{triggerLabel}</span>
198
+ {triggerHarness && (
199
+ <span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[0.6rem] font-medium text-muted-foreground">
200
+ {triggerHarness}
201
+ </span>
202
+ )}
203
+ <span className="shrink-0 text-[0.6rem] text-muted-foreground">{triggerCost}</span>
204
+ <ChevronIcon />
205
+ </Popover.Trigger>
206
+
207
+ <Popover.Portal>
208
+ <Popover.Positioner sideOffset={4}>
209
+ <Popover.Popup
210
+ role="dialog"
211
+ aria-label="Model selector"
212
+ className={cn(
213
+ "z-popover w-72 rounded-lg border border-border bg-popover shadow-md",
91
214
  "text-popover-foreground",
92
- ].join(" ")}
215
+ )}
93
216
  >
94
- {providers.map((provider) => {
95
- const models = byProvider.get(provider);
96
- if (!models?.length) return null;
97
-
98
- return (
99
- <Select.Group key={provider}>
100
- <Select.GroupLabel className="px-2 py-1.5 text-[0.65rem] font-medium uppercase tracking-wider text-muted-foreground">
101
- {PROVIDER_LABELS[provider]}
102
- </Select.GroupLabel>
103
- {models.map((model) => (
104
- <Select.Item
105
- key={model.modelId}
106
- value={model.modelId}
107
- className={[
108
- "flex cursor-pointer items-center justify-between gap-2",
109
- "rounded-md px-2 py-1.5 text-xs outline-none",
110
- "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
111
- "data-[selected]:font-medium",
112
- ].join(" ")}
113
- >
114
- <Select.ItemText>{model.displayName}</Select.ItemText>
115
- <span className="text-[0.6rem] text-muted-foreground">
116
- {COST_TIER_INDICATOR[model.costTier]}
217
+ {/* Search input */}
218
+ <div className="border-b border-border px-2 py-1.5">
219
+ <input
220
+ ref={searchRef}
221
+ role="searchbox"
222
+ aria-label="Search models"
223
+ placeholder="Search models…"
224
+ value={searchQuery}
225
+ onChange={(e) => setSearchQuery(e.target.value)}
226
+ onKeyDown={handleKeyDown}
227
+ className={cn(
228
+ "w-full bg-transparent text-xs text-foreground placeholder:text-muted-foreground",
229
+ "outline-none",
230
+ )}
231
+ />
232
+ </div>
233
+
234
+ {/* Model list */}
235
+ <div
236
+ ref={listRef}
237
+ role="listbox"
238
+ aria-label="Available models"
239
+ className="max-h-64 overflow-y-auto p-1"
240
+ >
241
+ {visibleModels.length === 0 && (
242
+ <div className="px-2 py-3 text-center text-xs text-muted-foreground">
243
+ No models found
244
+ </div>
245
+ )}
246
+ {visibleModels.map((model, idx) => {
247
+ const key = modelKey(model.harness, model.modelId);
248
+ const isSelected = selectedModel
249
+ ? model.harness === selectedModel.harness && model.modelId === selectedModel.modelId
250
+ : false;
251
+ const isHighlighted = idx === highlightIdx;
252
+
253
+ return (
254
+ <button
255
+ key={key}
256
+ data-model-option=""
257
+ role="option"
258
+ aria-selected={isSelected}
259
+ type="button"
260
+ className={cn(
261
+ "flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none",
262
+ "transition-colors",
263
+ isHighlighted && "bg-accent text-accent-foreground",
264
+ !isHighlighted && "hover:bg-accent-hover",
265
+ isSelected && "font-medium",
266
+ )}
267
+ onClick={() => selectModel(model)}
268
+ onMouseEnter={() => setHighlightIdx(idx)}
269
+ >
270
+ {/* Model name */}
271
+ <span className="flex-1 truncate text-left">{model.displayName}</span>
272
+
273
+ {/* Engine tag (unified mode only) */}
274
+ {isUnified && (
275
+ <span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[0.55rem] font-medium text-muted-foreground">
276
+ {HARNESS_LABELS[model.harness]}
117
277
  </span>
118
- </Select.Item>
119
- ))}
120
- </Select.Group>
121
- );
122
- })}
123
- </Select.Popup>
124
- </Select.Positioner>
125
- </Select.Portal>
126
- </Select.Root>
278
+ )}
279
+
280
+ {/* Cost tier */}
281
+ <span className="shrink-0 text-[0.6rem] text-muted-foreground">
282
+ {COST_TIER_LABEL[model.costTier]}
283
+ </span>
284
+
285
+ {/* Selected checkmark */}
286
+ {isSelected && (
287
+ <CheckIcon className="shrink-0 text-primary" />
288
+ )}
289
+ </button>
290
+ );
291
+ })}
292
+
293
+ {/* Show All Models */}
294
+ {showShowAllButton && (
295
+ <button
296
+ type="button"
297
+ className={cn(
298
+ "mt-1 flex w-full items-center justify-center rounded-md border border-dashed border-border",
299
+ "px-2 py-1.5 text-xs text-muted-foreground",
300
+ "hover:bg-accent-hover hover:text-foreground transition-colors cursor-pointer",
301
+ )}
302
+ onClick={() => setShowAll(true)}
303
+ >
304
+ Show All Models
305
+ </button>
306
+ )}
307
+ </div>
308
+ </Popover.Popup>
309
+ </Popover.Positioner>
310
+ </Popover.Portal>
311
+ </Popover.Root>
127
312
  );
128
313
  }
129
314
 
130
315
  function ChevronIcon() {
131
316
  return (
132
317
  <svg
318
+ className="shrink-0 text-muted-foreground"
133
319
  width="10"
134
320
  height="10"
135
321
  viewBox="0 0 10 10"
@@ -143,3 +329,21 @@ function ChevronIcon() {
143
329
  </svg>
144
330
  );
145
331
  }
332
+
333
+ function CheckIcon({ className }: { className?: string }) {
334
+ return (
335
+ <svg
336
+ className={className}
337
+ width="12"
338
+ height="12"
339
+ viewBox="0 0 12 12"
340
+ fill="none"
341
+ stroke="currentColor"
342
+ strokeWidth="2"
343
+ strokeLinecap="round"
344
+ strokeLinejoin="round"
345
+ >
346
+ <path d="M2 6L5 9L10 3" />
347
+ </svg>
348
+ );
349
+ }
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
+ import { HarnessSelector } from "../HarnessSelector";
4
+
5
+ function renderSelector(
6
+ overrides: Partial<Parameters<typeof HarnessSelector>[0]> = {},
7
+ ) {
8
+ const onValueChange = overrides.onValueChange ?? vi.fn();
9
+ const result = render(
10
+ <HarnessSelector
11
+ value={overrides.value ?? "native"}
12
+ onValueChange={onValueChange}
13
+ disabled={overrides.disabled}
14
+ className={overrides.className}
15
+ />,
16
+ );
17
+ return { ...result, onValueChange };
18
+ }
19
+
20
+ afterEach(cleanup);
21
+
22
+ describe("HarnessSelector", () => {
23
+ describe("ARIA structure", () => {
24
+ it("renders a radiogroup with correct aria-label", () => {
25
+ renderSelector();
26
+ const group = screen.getByRole("radiogroup", { name: "Execution engine" });
27
+ expect(group).toBeDefined();
28
+ });
29
+
30
+ it("renders exactly two radio buttons", () => {
31
+ renderSelector();
32
+ const radios = screen.getAllByRole("radio");
33
+ expect(radios).toHaveLength(2);
34
+ });
35
+
36
+ it("labels radio buttons with user-facing harness names", () => {
37
+ renderSelector();
38
+ expect(screen.getByRole("radio", { name: "Stigmer" })).toBeDefined();
39
+ expect(screen.getByRole("radio", { name: "Cursor" })).toBeDefined();
40
+ });
41
+ });
42
+
43
+ describe("checked state", () => {
44
+ it("marks native as checked when value is native", () => {
45
+ renderSelector({ value: "native" });
46
+ const native = screen.getByRole("radio", { name: "Stigmer" });
47
+ const cursor = screen.getByRole("radio", { name: "Cursor" });
48
+ expect(native.getAttribute("aria-checked")).toBe("true");
49
+ expect(cursor.getAttribute("aria-checked")).toBe("false");
50
+ });
51
+
52
+ it("marks cursor as checked when value is cursor", () => {
53
+ renderSelector({ value: "cursor" });
54
+ const native = screen.getByRole("radio", { name: "Stigmer" });
55
+ const cursor = screen.getByRole("radio", { name: "Cursor" });
56
+ expect(native.getAttribute("aria-checked")).toBe("false");
57
+ expect(cursor.getAttribute("aria-checked")).toBe("true");
58
+ });
59
+ });
60
+
61
+ describe("roving tabIndex", () => {
62
+ it("sets tabIndex 0 on the active option and -1 on inactive", () => {
63
+ renderSelector({ value: "native" });
64
+ const native = screen.getByRole("radio", { name: "Stigmer" });
65
+ const cursor = screen.getByRole("radio", { name: "Cursor" });
66
+ expect(native.tabIndex).toBe(0);
67
+ expect(cursor.tabIndex).toBe(-1);
68
+ });
69
+
70
+ it("reverses tabIndex when cursor is active", () => {
71
+ renderSelector({ value: "cursor" });
72
+ const native = screen.getByRole("radio", { name: "Stigmer" });
73
+ const cursor = screen.getByRole("radio", { name: "Cursor" });
74
+ expect(native.tabIndex).toBe(-1);
75
+ expect(cursor.tabIndex).toBe(0);
76
+ });
77
+ });
78
+
79
+ describe("click interaction", () => {
80
+ it("fires onValueChange when clicking an inactive option", () => {
81
+ const { onValueChange } = renderSelector({ value: "native" });
82
+ fireEvent.click(screen.getByRole("radio", { name: "Cursor" }));
83
+ expect(onValueChange).toHaveBeenCalledWith("cursor");
84
+ });
85
+
86
+ it("does not fire onValueChange when clicking the active option", () => {
87
+ const { onValueChange } = renderSelector({ value: "native" });
88
+ fireEvent.click(screen.getByRole("radio", { name: "Stigmer" }));
89
+ expect(onValueChange).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("keyboard navigation", () => {
94
+ it("ArrowRight from native selects cursor", () => {
95
+ const { onValueChange } = renderSelector({ value: "native" });
96
+ const group = screen.getByRole("radiogroup");
97
+ fireEvent.keyDown(group, { key: "ArrowRight" });
98
+ expect(onValueChange).toHaveBeenCalledWith("cursor");
99
+ });
100
+
101
+ it("ArrowLeft from native wraps to cursor", () => {
102
+ const { onValueChange } = renderSelector({ value: "native" });
103
+ const group = screen.getByRole("radiogroup");
104
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
105
+ expect(onValueChange).toHaveBeenCalledWith("cursor");
106
+ });
107
+
108
+ it("ArrowRight from cursor wraps to native", () => {
109
+ const { onValueChange } = renderSelector({ value: "cursor" });
110
+ const group = screen.getByRole("radiogroup");
111
+ fireEvent.keyDown(group, { key: "ArrowRight" });
112
+ expect(onValueChange).toHaveBeenCalledWith("native");
113
+ });
114
+
115
+ it("ArrowDown behaves the same as ArrowRight", () => {
116
+ const { onValueChange } = renderSelector({ value: "native" });
117
+ const group = screen.getByRole("radiogroup");
118
+ fireEvent.keyDown(group, { key: "ArrowDown" });
119
+ expect(onValueChange).toHaveBeenCalledWith("cursor");
120
+ });
121
+
122
+ it("ArrowUp behaves the same as ArrowLeft", () => {
123
+ const { onValueChange } = renderSelector({ value: "cursor" });
124
+ const group = screen.getByRole("radiogroup");
125
+ fireEvent.keyDown(group, { key: "ArrowUp" });
126
+ expect(onValueChange).toHaveBeenCalledWith("native");
127
+ });
128
+
129
+ it("ignores non-arrow keys", () => {
130
+ const { onValueChange } = renderSelector({ value: "native" });
131
+ const group = screen.getByRole("radiogroup");
132
+ fireEvent.keyDown(group, { key: "Enter" });
133
+ fireEvent.keyDown(group, { key: " " });
134
+ fireEvent.keyDown(group, { key: "Tab" });
135
+ expect(onValueChange).not.toHaveBeenCalled();
136
+ });
137
+ });
138
+
139
+ describe("disabled state", () => {
140
+ it("does not fire onValueChange on click when disabled", () => {
141
+ const { onValueChange } = renderSelector({
142
+ value: "native",
143
+ disabled: true,
144
+ });
145
+ fireEvent.click(screen.getByRole("radio", { name: "Cursor" }));
146
+ expect(onValueChange).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("does not fire onValueChange on keyboard when disabled", () => {
150
+ const { onValueChange } = renderSelector({
151
+ value: "native",
152
+ disabled: true,
153
+ });
154
+ const group = screen.getByRole("radiogroup");
155
+ fireEvent.keyDown(group, { key: "ArrowRight" });
156
+ expect(onValueChange).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("marks both radio buttons as disabled", () => {
160
+ renderSelector({ disabled: true });
161
+ const radios = screen.getAllByRole("radio");
162
+ for (const radio of radios) {
163
+ expect((radio as HTMLButtonElement).disabled).toBe(true);
164
+ }
165
+ });
166
+ });
167
+
168
+ describe("premium indicator", () => {
169
+ it("shows premium indicator on the cursor option", () => {
170
+ renderSelector({ value: "native" });
171
+ const premiumBadge = screen.getByLabelText("premium");
172
+ expect(premiumBadge).toBeDefined();
173
+ expect(premiumBadge.textContent).toBe("$$$");
174
+ });
175
+
176
+ it("does not show premium indicator on the native option", () => {
177
+ renderSelector({ value: "native" });
178
+ const premiumBadges = screen.getAllByLabelText("premium");
179
+ expect(premiumBadges).toHaveLength(1);
180
+ });
181
+ });
182
+
183
+ describe("className passthrough", () => {
184
+ it("appends custom className to the root container", () => {
185
+ renderSelector({ className: "my-custom-class" });
186
+ const group = screen.getByRole("radiogroup");
187
+ expect(group.className).toContain("my-custom-class");
188
+ });
189
+ });
190
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
3
+ import {
4
+ toProtoHarness,
5
+ fromProtoHarness,
6
+ DEFAULT_HARNESS,
7
+ HARNESS_LABELS,
8
+ type HarnessOption,
9
+ } from "../harness";
10
+
11
+ describe("harness constants", () => {
12
+ it("defaults to native harness", () => {
13
+ expect(DEFAULT_HARNESS).toBe("native");
14
+ });
15
+
16
+ it("provides user-facing labels for both options", () => {
17
+ expect(HARNESS_LABELS.native).toBe("Stigmer");
18
+ expect(HARNESS_LABELS.cursor).toBe("Cursor");
19
+ });
20
+
21
+ it("covers every HarnessOption in HARNESS_LABELS", () => {
22
+ const options: HarnessOption[] = ["native", "cursor"];
23
+ for (const opt of options) {
24
+ expect(HARNESS_LABELS[opt]).toBeDefined();
25
+ expect(typeof HARNESS_LABELS[opt]).toBe("string");
26
+ }
27
+ });
28
+ });
29
+
30
+ describe("toProtoHarness", () => {
31
+ it("maps native to Harness.NATIVE", () => {
32
+ expect(toProtoHarness("native")).toBe(Harness.NATIVE);
33
+ });
34
+
35
+ it("maps cursor to Harness.CURSOR", () => {
36
+ expect(toProtoHarness("cursor")).toBe(Harness.CURSOR);
37
+ });
38
+ });
39
+
40
+ describe("fromProtoHarness", () => {
41
+ it("maps Harness.NATIVE to native", () => {
42
+ expect(fromProtoHarness(Harness.NATIVE)).toBe("native");
43
+ });
44
+
45
+ it("maps Harness.CURSOR to cursor", () => {
46
+ expect(fromProtoHarness(Harness.CURSOR)).toBe("cursor");
47
+ });
48
+
49
+ it("maps Harness.UNSPECIFIED to native (safe default)", () => {
50
+ expect(fromProtoHarness(Harness.UNSPECIFIED)).toBe("native");
51
+ });
52
+
53
+ it("maps unknown numeric values to native (safe default)", () => {
54
+ expect(fromProtoHarness(999 as Harness)).toBe("native");
55
+ });
56
+ });
57
+
58
+ describe("round-trip conversion", () => {
59
+ it("native survives toProto -> fromProto", () => {
60
+ expect(fromProtoHarness(toProtoHarness("native"))).toBe("native");
61
+ });
62
+
63
+ it("cursor survives toProto -> fromProto", () => {
64
+ expect(fromProtoHarness(toProtoHarness("cursor"))).toBe("cursor");
65
+ });
66
+ });