@stigmer/react 0.4.6 → 0.4.8
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.map +1 -1
- package/composer/ComposerToolbar.js +1 -1
- package/composer/ComposerToolbar.js.map +1 -1
- package/execution/MessageThread.d.ts +1 -0
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +22 -12
- package/execution/MessageThread.js.map +1 -1
- package/execution/SetupProgress.d.ts +23 -9
- package/execution/SetupProgress.d.ts.map +1 -1
- package/execution/SetupProgress.js +30 -14
- package/execution/SetupProgress.js.map +1 -1
- package/execution/__tests__/thread-keys.test.js +82 -0
- package/execution/__tests__/thread-keys.test.js.map +1 -1
- package/models/ModelSelector.d.ts +9 -1
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +7 -2
- package/models/ModelSelector.js.map +1 -1
- package/models/registry.d.ts +4 -1
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +6 -2
- package/models/registry.js.map +1 -1
- package/package.json +4 -4
- package/provider.js +1 -1
- package/provider.js.map +1 -1
- package/session/__tests__/useNewSessionFlow.test.js +57 -0
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +20 -6
- package/session/useNewSessionFlow.js.map +1 -1
- package/src/composer/ComposerToolbar.tsx +1 -0
- package/src/execution/MessageThread.tsx +26 -15
- package/src/execution/SetupProgress.tsx +35 -14
- package/src/execution/__tests__/thread-keys.test.ts +101 -0
- package/src/models/ModelSelector.tsx +18 -1
- package/src/models/registry.ts +10 -2
- package/src/provider.tsx +1 -1
- package/src/session/__tests__/useNewSessionFlow.test.tsx +81 -0
- package/src/session/useNewSessionFlow.ts +17 -6
|
@@ -32,6 +32,14 @@ export interface ModelSelectorProps {
|
|
|
32
32
|
* to that harness (dropdown hidden). When omitted, shows the harness dropdown.
|
|
33
33
|
*/
|
|
34
34
|
readonly harness?: HarnessOption;
|
|
35
|
+
/**
|
|
36
|
+
* Initial harness value for the internal state when `harness` prop is
|
|
37
|
+
* undefined (unlocked mode). Prevents desync when the parent knows the
|
|
38
|
+
* active harness but delegates the dropdown to this component.
|
|
39
|
+
*
|
|
40
|
+
* When `harness` is provided (locked mode), this prop is ignored.
|
|
41
|
+
*/
|
|
42
|
+
readonly initialHarness?: HarnessOption;
|
|
35
43
|
/** Called when user changes harness in the dropdown. */
|
|
36
44
|
readonly onHarnessChange?: (harness: HarnessOption) => void;
|
|
37
45
|
/**
|
|
@@ -85,6 +93,7 @@ export function ModelSelector({
|
|
|
85
93
|
value,
|
|
86
94
|
onValueChange,
|
|
87
95
|
harness,
|
|
96
|
+
initialHarness,
|
|
88
97
|
onHarnessChange,
|
|
89
98
|
onHarnessResolved,
|
|
90
99
|
availableHarnesses,
|
|
@@ -99,9 +108,17 @@ export function ModelSelector({
|
|
|
99
108
|
const portalContainer = useStigmerPortalContainer();
|
|
100
109
|
|
|
101
110
|
const isHarnessLocked = harness !== undefined;
|
|
102
|
-
const [internalHarness, setInternalHarness] = useState<HarnessOption>(
|
|
111
|
+
const [internalHarness, setInternalHarness] = useState<HarnessOption>(
|
|
112
|
+
harness ?? initialHarness ?? "native",
|
|
113
|
+
);
|
|
103
114
|
const activeHarness = harness ?? internalHarness;
|
|
104
115
|
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!isHarnessLocked && initialHarness !== undefined) {
|
|
118
|
+
setInternalHarness(initialHarness);
|
|
119
|
+
}
|
|
120
|
+
}, [initialHarness, isHarnessLocked]);
|
|
121
|
+
|
|
105
122
|
const { models, featured, defaultModel, getModel, byProvider } = useModelRegistry(
|
|
106
123
|
{ harness: activeHarness },
|
|
107
124
|
);
|
package/src/models/registry.ts
CHANGED
|
@@ -200,14 +200,22 @@ export function parseRegistryJson(data: unknown): ModelInfo[] {
|
|
|
200
200
|
*
|
|
201
201
|
* @param apiUrl - Base URL of the Stigmer Cloud API (e.g. `https://api.stigmer.ai`)
|
|
202
202
|
* @param token - Bearer token for authentication (from `client.getAuthCredential()`)
|
|
203
|
+
* @param customFetch - Optional custom `fetch` implementation. Required in
|
|
204
|
+
* Tauri where the global `fetch` is restricted by webview CSP/CORS policies.
|
|
205
|
+
* When omitted, the global `fetch` is used.
|
|
203
206
|
* @returns Parsed `ModelInfo[]`.
|
|
204
207
|
*/
|
|
205
|
-
export async function fetchModelRegistry(
|
|
208
|
+
export async function fetchModelRegistry(
|
|
209
|
+
apiUrl: string,
|
|
210
|
+
token: string | null,
|
|
211
|
+
customFetch?: typeof globalThis.fetch,
|
|
212
|
+
): Promise<ModelInfo[]> {
|
|
213
|
+
const doFetch = customFetch ?? globalThis.fetch;
|
|
206
214
|
const headers: Record<string, string> = {};
|
|
207
215
|
if (token) {
|
|
208
216
|
headers["Authorization"] = `Bearer ${token}`;
|
|
209
217
|
}
|
|
210
|
-
const res = await
|
|
218
|
+
const res = await doFetch(`${apiUrl}/v1/proxy/model-registry`, { headers });
|
|
211
219
|
if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
|
|
212
220
|
const data: unknown = await res.json();
|
|
213
221
|
return parseRegistryJson(data);
|
package/src/provider.tsx
CHANGED
|
@@ -173,7 +173,7 @@ function useModelRegistryFetch(client: Stigmer): ModelRegistryState {
|
|
|
173
173
|
|
|
174
174
|
const c = clientRef.current;
|
|
175
175
|
c.getAuthCredential()
|
|
176
|
-
.then((token) => fetchModelRegistry(c.baseUrl, token))
|
|
176
|
+
.then((token) => fetchModelRegistry(c.baseUrl, token, c.fetch))
|
|
177
177
|
.then((models) => {
|
|
178
178
|
if (!cancelled) {
|
|
179
179
|
setState({ models, isLoading: false, error: null });
|
|
@@ -61,6 +61,17 @@ vi.mock("../../execution/useSessionVariables", () => ({
|
|
|
61
61
|
useSessionVariables: () => mockSessionVariables,
|
|
62
62
|
}));
|
|
63
63
|
|
|
64
|
+
const mockRunnerList = {
|
|
65
|
+
runners: [] as { metadata?: { id: string; name?: string } }[],
|
|
66
|
+
isLoading: false,
|
|
67
|
+
isRefetching: false,
|
|
68
|
+
error: null,
|
|
69
|
+
refetch: vi.fn(),
|
|
70
|
+
};
|
|
71
|
+
vi.mock("../../runner/useRunnerList", () => ({
|
|
72
|
+
useRunnerList: () => mockRunnerList,
|
|
73
|
+
}));
|
|
74
|
+
|
|
64
75
|
import { useNewSessionFlow } from "../useNewSessionFlow";
|
|
65
76
|
|
|
66
77
|
const TEST_MODELS = parseRegistryJson({
|
|
@@ -101,6 +112,8 @@ describe("useNewSessionFlow", () => {
|
|
|
101
112
|
};
|
|
102
113
|
mockDefaultAgent.isLoading = false;
|
|
103
114
|
mockDefaultAgent.error = null;
|
|
115
|
+
mockRunnerList.runners = [];
|
|
116
|
+
mockRunnerList.isLoading = false;
|
|
104
117
|
mockCreateSession.mockResolvedValue({ sessionId: "sess-new" });
|
|
105
118
|
mockCreateExecution.mockResolvedValue({});
|
|
106
119
|
});
|
|
@@ -235,6 +248,74 @@ describe("useNewSessionFlow", () => {
|
|
|
235
248
|
});
|
|
236
249
|
});
|
|
237
250
|
|
|
251
|
+
describe("runner validation gate", () => {
|
|
252
|
+
it("restores runner when it exists in the live runner list", () => {
|
|
253
|
+
localStorage.setItem("stigmer:session:runner", "runner-abc");
|
|
254
|
+
mockRunnerList.runners = [{ metadata: { id: "runner-abc", name: "My Runner" } }];
|
|
255
|
+
mockRunnerList.isLoading = false;
|
|
256
|
+
|
|
257
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
258
|
+
|
|
259
|
+
expect(result.current.runnerId).toBe("runner-abc");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("discards stale runner that no longer exists in the runner list", () => {
|
|
263
|
+
localStorage.setItem("stigmer:session:runner", "deleted-runner");
|
|
264
|
+
mockRunnerList.runners = [{ metadata: { id: "runner-abc", name: "My Runner" } }];
|
|
265
|
+
mockRunnerList.isLoading = false;
|
|
266
|
+
|
|
267
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
268
|
+
|
|
269
|
+
expect(result.current.runnerId).toBeNull();
|
|
270
|
+
expect(localStorage.getItem("stigmer:session:runner")).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("does not restore runner while runner list is loading", () => {
|
|
274
|
+
localStorage.setItem("stigmer:session:runner", "runner-abc");
|
|
275
|
+
mockRunnerList.runners = [];
|
|
276
|
+
mockRunnerList.isLoading = true;
|
|
277
|
+
|
|
278
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
279
|
+
|
|
280
|
+
expect(result.current.runnerId).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("model validation timing", () => {
|
|
285
|
+
it("does not restore model while registry is loading", () => {
|
|
286
|
+
localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, DEFAULT_MODEL_ID);
|
|
287
|
+
const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null };
|
|
288
|
+
|
|
289
|
+
function LoadingWrapper({ children }: { children: ReactNode }) {
|
|
290
|
+
return (
|
|
291
|
+
<ModelRegistryContext.Provider value={loadingState}>
|
|
292
|
+
{children}
|
|
293
|
+
</ModelRegistryContext.Provider>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: LoadingWrapper });
|
|
298
|
+
|
|
299
|
+
expect(result.current.modelId).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("restores model once registry has loaded", () => {
|
|
303
|
+
localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, DEFAULT_MODEL_ID);
|
|
304
|
+
|
|
305
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
306
|
+
|
|
307
|
+
expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("discards model that no longer exists in the registry", () => {
|
|
311
|
+
localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, "removed-model-xyz");
|
|
312
|
+
|
|
313
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
314
|
+
|
|
315
|
+
expect(result.current.modelId).toBeUndefined();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
238
319
|
describe("submit with harness", () => {
|
|
239
320
|
it("passes harness field to createSession", async () => {
|
|
240
321
|
const opts = defaultOptions();
|
|
@@ -10,6 +10,7 @@ import { DEFAULT_HARNESS, type HarnessOption } from "../models/harness";
|
|
|
10
10
|
import { useWorkspaceEntries, type UseWorkspaceEntriesReturn } from "../workspace";
|
|
11
11
|
import { useSessionVariables, type UseSessionVariablesReturn } from "../execution/useSessionVariables";
|
|
12
12
|
import type { SessionComposerSubmitContext } from "../composer";
|
|
13
|
+
import { useRunnerList } from "../runner/useRunnerList";
|
|
13
14
|
import { useCreateSession } from "./useCreateSession";
|
|
14
15
|
import { useCreateAgentExecution } from "../execution/useCreateAgentExecution";
|
|
15
16
|
|
|
@@ -155,7 +156,8 @@ export function useNewSessionFlow(
|
|
|
155
156
|
return stored === "cursor" ? "cursor" : DEFAULT_HARNESS;
|
|
156
157
|
});
|
|
157
158
|
|
|
158
|
-
const { getModel } = useModelRegistry({ harness });
|
|
159
|
+
const { getModel, isLoading: isModelsLoading } = useModelRegistry({ harness });
|
|
160
|
+
const { runners, isLoading: isRunnersLoading } = useRunnerList(org);
|
|
159
161
|
const { create: createSession } = useCreateSession();
|
|
160
162
|
const { create: createExecution } = useCreateAgentExecution();
|
|
161
163
|
const {
|
|
@@ -192,8 +194,10 @@ export function useNewSessionFlow(
|
|
|
192
194
|
[],
|
|
193
195
|
);
|
|
194
196
|
|
|
195
|
-
// Restore persisted model
|
|
197
|
+
// Restore persisted model — only after the registry has loaded so
|
|
198
|
+
// getModel can actually validate the stored ID against live data.
|
|
196
199
|
useEffect(() => {
|
|
200
|
+
if (isModelsLoading) return;
|
|
197
201
|
const stored = localStorage.getItem(modelStorageKey(harness));
|
|
198
202
|
if (stored) {
|
|
199
203
|
const plain = parseModelKey(stored)?.modelId ?? stored;
|
|
@@ -201,7 +205,7 @@ export function useNewSessionFlow(
|
|
|
201
205
|
setModelId(plain);
|
|
202
206
|
}
|
|
203
207
|
}
|
|
204
|
-
}, [getModel, harness]);
|
|
208
|
+
}, [getModel, harness, isModelsLoading]);
|
|
205
209
|
|
|
206
210
|
// Persist model on change (using current harness key).
|
|
207
211
|
// Strip compound keys (e.g. "cursor/default") to plain modelId before storing.
|
|
@@ -212,13 +216,20 @@ export function useNewSessionFlow(
|
|
|
212
216
|
}
|
|
213
217
|
}, [modelId, harness]);
|
|
214
218
|
|
|
215
|
-
// Restore persisted runner
|
|
219
|
+
// Restore persisted runner — validate against the live runner list.
|
|
220
|
+
// If the stored runner no longer exists, discard it and clean up localStorage.
|
|
216
221
|
useEffect(() => {
|
|
222
|
+
if (isRunnersLoading) return;
|
|
217
223
|
const stored = localStorage.getItem(STORAGE_KEY_RUNNER);
|
|
218
|
-
if (stored)
|
|
224
|
+
if (!stored) return;
|
|
225
|
+
|
|
226
|
+
const exists = runners.some((r) => r.metadata?.id === stored);
|
|
227
|
+
if (exists) {
|
|
219
228
|
setRunnerId(stored);
|
|
229
|
+
} else {
|
|
230
|
+
localStorage.removeItem(STORAGE_KEY_RUNNER);
|
|
220
231
|
}
|
|
221
|
-
}, []);
|
|
232
|
+
}, [runners, isRunnersLoading]);
|
|
222
233
|
|
|
223
234
|
// Persist runner on change
|
|
224
235
|
useEffect(() => {
|