@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.
Files changed (38) hide show
  1. package/composer/ComposerToolbar.d.ts.map +1 -1
  2. package/composer/ComposerToolbar.js +1 -1
  3. package/composer/ComposerToolbar.js.map +1 -1
  4. package/execution/MessageThread.d.ts +1 -0
  5. package/execution/MessageThread.d.ts.map +1 -1
  6. package/execution/MessageThread.js +22 -12
  7. package/execution/MessageThread.js.map +1 -1
  8. package/execution/SetupProgress.d.ts +23 -9
  9. package/execution/SetupProgress.d.ts.map +1 -1
  10. package/execution/SetupProgress.js +30 -14
  11. package/execution/SetupProgress.js.map +1 -1
  12. package/execution/__tests__/thread-keys.test.js +82 -0
  13. package/execution/__tests__/thread-keys.test.js.map +1 -1
  14. package/models/ModelSelector.d.ts +9 -1
  15. package/models/ModelSelector.d.ts.map +1 -1
  16. package/models/ModelSelector.js +7 -2
  17. package/models/ModelSelector.js.map +1 -1
  18. package/models/registry.d.ts +4 -1
  19. package/models/registry.d.ts.map +1 -1
  20. package/models/registry.js +6 -2
  21. package/models/registry.js.map +1 -1
  22. package/package.json +4 -4
  23. package/provider.js +1 -1
  24. package/provider.js.map +1 -1
  25. package/session/__tests__/useNewSessionFlow.test.js +57 -0
  26. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  27. package/session/useNewSessionFlow.d.ts.map +1 -1
  28. package/session/useNewSessionFlow.js +20 -6
  29. package/session/useNewSessionFlow.js.map +1 -1
  30. package/src/composer/ComposerToolbar.tsx +1 -0
  31. package/src/execution/MessageThread.tsx +26 -15
  32. package/src/execution/SetupProgress.tsx +35 -14
  33. package/src/execution/__tests__/thread-keys.test.ts +101 -0
  34. package/src/models/ModelSelector.tsx +18 -1
  35. package/src/models/registry.ts +10 -2
  36. package/src/provider.tsx +1 -1
  37. package/src/session/__tests__/useNewSessionFlow.test.tsx +81 -0
  38. 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>(harness ?? "native");
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
  );
@@ -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(apiUrl: string, token: string | null): Promise<ModelInfo[]> {
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 fetch(`${apiUrl}/v1/proxy/model-registry`, { headers });
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 on mount (using current harness key)
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 on mount
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(() => {