@stigmer/react 0.2.2 → 0.3.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.
- package/composer/ComposerToolbar.d.ts +5 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +6 -3
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +17 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +32 -35
- package/composer/SessionComposer.js.map +1 -1
- package/execution/MessageEntry.d.ts +3 -1
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +30 -1
- package/execution/MessageEntry.js.map +1 -1
- package/github/index.d.ts +1 -1
- package/github/index.d.ts.map +1 -1
- package/github/index.js.map +1 -1
- package/github/useGitHubConnection.d.ts +70 -1
- package/github/useGitHubConnection.d.ts.map +1 -1
- package/github/useGitHubConnection.js +99 -20
- package/github/useGitHubConnection.js.map +1 -1
- package/identity-provider/IdentityProviderWizard.d.ts.map +1 -1
- package/identity-provider/IdentityProviderWizard.js +19 -3
- package/identity-provider/IdentityProviderWizard.js.map +1 -1
- package/index.d.ts +4 -4
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/models/HarnessSelector.d.ts +41 -0
- package/models/HarnessSelector.d.ts.map +1 -0
- package/models/HarnessSelector.js +74 -0
- package/models/HarnessSelector.js.map +1 -0
- package/models/ModelSelector.d.ts +26 -16
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +128 -48
- package/models/ModelSelector.js.map +1 -1
- package/models/__tests__/HarnessSelector.test.d.ts +2 -0
- package/models/__tests__/HarnessSelector.test.d.ts.map +1 -0
- package/models/__tests__/HarnessSelector.test.js +160 -0
- package/models/__tests__/HarnessSelector.test.js.map +1 -0
- package/models/__tests__/harness.test.d.ts +2 -0
- package/models/__tests__/harness.test.d.ts.map +1 -0
- package/models/__tests__/harness.test.js +50 -0
- package/models/__tests__/harness.test.js.map +1 -0
- package/models/__tests__/useModelRegistry.test.d.ts +2 -0
- package/models/__tests__/useModelRegistry.test.d.ts.map +1 -0
- package/models/__tests__/useModelRegistry.test.js +148 -0
- package/models/__tests__/useModelRegistry.test.js.map +1 -0
- package/models/harness.d.ts +21 -0
- package/models/harness.d.ts.map +1 -0
- package/models/harness.js +34 -0
- package/models/harness.js.map +1 -0
- package/models/index.d.ts +7 -2
- package/models/index.d.ts.map +1 -1
- package/models/index.js +3 -1
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +53 -13
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +51 -40
- package/models/registry.js.map +1 -1
- package/models/useModelRegistry.d.ts +39 -19
- package/models/useModelRegistry.d.ts.map +1 -1
- package/models/useModelRegistry.js +45 -23
- package/models/useModelRegistry.js.map +1 -1
- package/organization/OrgProfilePanel.d.ts.map +1 -1
- package/organization/OrgProfilePanel.js +23 -2
- package/organization/OrgProfilePanel.js.map +1 -1
- package/package.json +4 -4
- package/runner/RunnerFileBrowser.d.ts +11 -1
- package/runner/RunnerFileBrowser.d.ts.map +1 -1
- package/runner/RunnerFileBrowser.js +70 -7
- package/runner/RunnerFileBrowser.js.map +1 -1
- package/runner/RunnerListPanel.js +2 -1
- package/runner/RunnerListPanel.js.map +1 -1
- package/runner/WorkspaceRunnerSelector.d.ts +36 -0
- package/runner/WorkspaceRunnerSelector.d.ts.map +1 -0
- package/runner/WorkspaceRunnerSelector.js +63 -0
- package/runner/WorkspaceRunnerSelector.js.map +1 -0
- package/runner/__tests__/phase.test.js +6 -2
- package/runner/__tests__/phase.test.js.map +1 -1
- package/runner/index.d.ts +2 -0
- package/runner/index.d.ts.map +1 -1
- package/runner/index.js +1 -0
- package/runner/index.js.map +1 -1
- package/runner/phase.d.ts +9 -7
- package/runner/phase.d.ts.map +1 -1
- package/runner/phase.js +18 -12
- package/runner/phase.js.map +1 -1
- package/runner/useRunnerFileBrowser.d.ts.map +1 -1
- package/runner/useRunnerFileBrowser.js +26 -2
- package/runner/useRunnerFileBrowser.js.map +1 -1
- package/session/__tests__/useCreateSession.test.d.ts +2 -0
- package/session/__tests__/useCreateSession.test.d.ts.map +1 -0
- package/session/__tests__/useCreateSession.test.js +232 -0
- package/session/__tests__/useCreateSession.test.js.map +1 -0
- package/session/__tests__/useNewSessionFlow.test.d.ts +2 -0
- package/session/__tests__/useNewSessionFlow.test.d.ts.map +1 -0
- package/session/__tests__/useNewSessionFlow.test.js +199 -0
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -0
- package/session/__tests__/useSessionConversation.test.js +37 -0
- package/session/__tests__/useSessionConversation.test.js.map +1 -1
- package/session/index.d.ts +1 -1
- package/session/index.d.ts.map +1 -1
- package/session/useCreateSession.d.ts +8 -0
- package/session/useCreateSession.d.ts.map +1 -1
- package/session/useCreateSession.js +2 -0
- package/session/useCreateSession.js.map +1 -1
- package/session/useNewSessionFlow.d.ts +6 -1
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +34 -8
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/usePersistedModel.d.ts +16 -1
- package/session/usePersistedModel.d.ts.map +1 -1
- package/session/usePersistedModel.js +15 -6
- package/session/usePersistedModel.js.map +1 -1
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +6 -1
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionPageFlow.d.ts +11 -0
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +11 -2
- package/session/useSessionPageFlow.js.map +1 -1
- package/settings/MembersSection.d.ts.map +1 -1
- package/settings/MembersSection.js +7 -2
- package/settings/MembersSection.js.map +1 -1
- package/src/composer/ComposerToolbar.tsx +24 -1
- package/src/composer/SessionComposer.tsx +81 -44
- package/src/execution/MessageEntry.tsx +134 -1
- package/src/github/index.ts +1 -0
- package/src/github/useGitHubConnection.ts +162 -22
- package/src/identity-provider/IdentityProviderWizard.tsx +112 -3
- package/src/index.ts +16 -1
- package/src/models/HarnessSelector.tsx +130 -0
- package/src/models/ModelSelector.tsx +285 -81
- package/src/models/__tests__/HarnessSelector.test.tsx +190 -0
- package/src/models/__tests__/harness.test.ts +66 -0
- package/src/models/__tests__/useModelRegistry.test.tsx +209 -0
- package/src/models/harness.ts +45 -0
- package/src/models/index.ts +7 -2
- package/src/models/registry.ts +122 -50
- package/src/models/useModelRegistry.ts +74 -24
- package/src/organization/OrgProfilePanel.tsx +98 -0
- package/src/runner/RunnerFileBrowser.tsx +227 -8
- package/src/runner/RunnerListPanel.tsx +13 -5
- package/src/runner/WorkspaceRunnerSelector.tsx +180 -0
- package/src/runner/__tests__/phase.test.ts +6 -2
- package/src/runner/index.ts +3 -0
- package/src/runner/phase.ts +18 -12
- package/src/runner/useRunnerFileBrowser.ts +39 -3
- package/src/session/__tests__/useCreateSession.test.tsx +296 -0
- package/src/session/__tests__/useNewSessionFlow.test.tsx +258 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/index.ts +1 -1
- package/src/session/useCreateSession.ts +9 -0
- package/src/session/useNewSessionFlow.ts +46 -9
- package/src/session/usePersistedModel.ts +30 -6
- package/src/session/useSessionConversation.ts +6 -1
- package/src/session/useSessionPageFlow.ts +26 -2
- package/src/settings/MembersSection.tsx +23 -1
- package/src/workspace/WorkspaceEditor.tsx +176 -126
- package/src/workspace/index.ts +5 -0
- package/src/workspace/useRecentWorkspaces.ts +162 -0
- package/src/workspace/useWorkspaceEntries.ts +13 -0
- package/styles.css +1 -1
- package/workspace/WorkspaceEditor.d.ts +25 -22
- package/workspace/WorkspaceEditor.d.ts.map +1 -1
- package/workspace/WorkspaceEditor.js +64 -43
- package/workspace/WorkspaceEditor.js.map +1 -1
- package/workspace/index.d.ts +2 -0
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -0
- package/workspace/index.js.map +1 -1
- package/workspace/useRecentWorkspaces.d.ts +31 -0
- package/workspace/useRecentWorkspaces.d.ts.map +1 -0
- package/workspace/useRecentWorkspaces.js +117 -0
- package/workspace/useRecentWorkspaces.js.map +1 -0
- package/workspace/useWorkspaceEntries.d.ts +8 -0
- package/workspace/useWorkspaceEntries.d.ts.map +1 -1
- package/workspace/useWorkspaceEntries.js +4 -0
- package/workspace/useWorkspaceEntries.js.map +1 -1
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
7
|
+
import { modelKey, parseModelKey, type ModelInfo, type CostTier } from "./registry";
|
|
8
|
+
import { HARNESS_LABELS, type HarnessOption } from "./harness";
|
|
6
9
|
|
|
7
|
-
const
|
|
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
|
|
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
|
|
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
|
-
*
|
|
33
|
-
* accessible keyboard navigation and ARIA.
|
|
39
|
+
* Cursor-style model picker: a flat searchable list inside a popover.
|
|
34
40
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
44
|
+
* Each model row shows the display name, an engine tag
|
|
45
|
+
* ("Stigmer" / "Cursor"), and a cost-tier indicator.
|
|
40
46
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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-[
|
|
193
|
+
"transition-colors max-w-[18rem] max-sm:max-w-[10rem]",
|
|
74
194
|
className,
|
|
75
|
-
|
|
76
|
-
.filter(Boolean)
|
|
77
|
-
.join(" ")}
|
|
195
|
+
)}
|
|
78
196
|
>
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
215
|
+
)}
|
|
93
216
|
>
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
});
|