@shipfox/client-agent 22.0.2 → 22.0.3

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 (73) hide show
  1. package/.storybook/preview.tsx +2 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/dist/components/form-errors.d.ts.map +1 -1
  5. package/dist/components/form-errors.js +3 -0
  6. package/dist/components/form-errors.js.map +1 -1
  7. package/dist/components/harness-availability.d.ts +2 -2
  8. package/dist/components/harness-availability.d.ts.map +1 -1
  9. package/dist/components/harness-availability.js +4 -3
  10. package/dist/components/harness-availability.js.map +1 -1
  11. package/dist/components/harnesses-section.d.ts.map +1 -1
  12. package/dist/components/harnesses-section.js +4 -2
  13. package/dist/components/harnesses-section.js.map +1 -1
  14. package/dist/components/model-provider-usage-modal.d.ts.map +1 -1
  15. package/dist/components/model-provider-usage-modal.js +23 -5
  16. package/dist/components/model-provider-usage-modal.js.map +1 -1
  17. package/dist/components/model-provider-usage-target.d.ts +6 -6
  18. package/dist/components/model-provider-usage-target.d.ts.map +1 -1
  19. package/dist/components/model-provider-usage-target.js +3 -1
  20. package/dist/components/model-provider-usage-target.js.map +1 -1
  21. package/dist/components/model-providers-section.d.ts.map +1 -1
  22. package/dist/components/model-providers-section.js +277 -170
  23. package/dist/components/model-providers-section.js.map +1 -1
  24. package/dist/components/model-providers-section.stories.d.ts +2 -1
  25. package/dist/components/model-providers-section.stories.d.ts.map +1 -1
  26. package/dist/components/model-providers-section.stories.js +45 -1
  27. package/dist/components/model-providers-section.stories.js.map +1 -1
  28. package/dist/core/harness-policy.d.ts +5 -2
  29. package/dist/core/harness-policy.d.ts.map +1 -1
  30. package/dist/core/harness-policy.js +7 -1
  31. package/dist/core/harness-policy.js.map +1 -1
  32. package/dist/core/models.d.ts +3 -0
  33. package/dist/core/models.d.ts.map +1 -1
  34. package/dist/core/models.js.map +1 -1
  35. package/dist/core/provider-policy.d.ts +3 -1
  36. package/dist/core/provider-policy.d.ts.map +1 -1
  37. package/dist/core/provider-policy.js +7 -0
  38. package/dist/core/provider-policy.js.map +1 -1
  39. package/dist/hooks/api/model-provider-mapper.d.ts.map +1 -1
  40. package/dist/hooks/api/model-provider-mapper.js +5 -1
  41. package/dist/hooks/api/model-provider-mapper.js.map +1 -1
  42. package/dist/pages/model-provider-onboarding-page.d.ts +1 -1
  43. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -1
  44. package/dist/pages/model-provider-onboarding-page.js +58 -44
  45. package/dist/pages/model-provider-onboarding-page.js.map +1 -1
  46. package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -1
  47. package/dist/pages/model-provider-onboarding-page.stories.js +5 -17
  48. package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -1
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/package.json +5 -5
  51. package/src/components/form-errors.test.ts +9 -0
  52. package/src/components/form-errors.ts +3 -0
  53. package/src/components/harness-availability.test.ts +49 -2
  54. package/src/components/harness-availability.ts +18 -3
  55. package/src/components/harnesses-section.test.tsx +51 -9
  56. package/src/components/harnesses-section.tsx +4 -1
  57. package/src/components/model-provider-usage-modal.test.tsx +23 -0
  58. package/src/components/model-provider-usage-modal.tsx +32 -6
  59. package/src/components/model-provider-usage-target.ts +9 -3
  60. package/src/components/model-providers-section.stories.tsx +33 -1
  61. package/src/components/model-providers-section.test.tsx +41 -0
  62. package/src/components/model-providers-section.tsx +264 -155
  63. package/src/core/harness-policy.ts +21 -1
  64. package/src/core/models.ts +3 -0
  65. package/src/core/provider-policy.ts +12 -0
  66. package/src/hooks/api/model-provider-mapper.test.ts +18 -0
  67. package/src/hooks/api/model-provider-mapper.ts +6 -1
  68. package/src/hooks/api/model-providers.test.ts +11 -0
  69. package/src/pages/model-provider-onboarding-page.stories.tsx +10 -14
  70. package/src/pages/model-provider-onboarding-page.test.tsx +133 -24
  71. package/src/pages/model-provider-onboarding-page.tsx +47 -34
  72. package/test/fixtures/model-providers.ts +22 -1
  73. package/tsconfig.build.tsbuildinfo +1 -1
@@ -131,25 +131,17 @@ export const Playground: Story = {};
131
131
 
132
132
  export const Loading: Story = {
133
133
  args: {scenario: 'loading'},
134
- play: async ({canvasElement}) => {
135
- const canvas = within(canvasElement);
136
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
137
- },
138
134
  };
139
135
 
140
136
  export const CatalogError: Story = {
141
137
  args: {scenario: 'catalog-error'},
142
- play: async ({canvasElement}) => {
143
- const canvas = within(canvasElement);
144
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
145
- },
146
138
  };
147
139
 
148
140
  export const NoProvidersAvailable: Story = {
149
141
  args: {scenario: 'no-providers'},
150
142
  play: async ({canvasElement}) => {
151
143
  const canvas = within(canvasElement);
152
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
144
+ await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
153
145
  },
154
146
  };
155
147
 
@@ -157,7 +149,7 @@ export const LongNames: Story = {
157
149
  args: {scenario: 'long-names'},
158
150
  play: async ({canvasElement}) => {
159
151
  const canvas = within(canvasElement);
160
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
152
+ await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
161
153
  },
162
154
  };
163
155
 
@@ -165,7 +157,7 @@ export const FilteredProviders: Story = {
165
157
  args: {scenario: 'available'},
166
158
  play: async ({canvasElement}) => {
167
159
  const canvas = within(canvasElement);
168
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
160
+ await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
169
161
  await userEvent.type(
170
162
  await canvas.findByRole('searchbox', {name: 'Search providers'}),
171
163
  'openrouter',
@@ -178,7 +170,7 @@ export const NoMatchingProviders: Story = {
178
170
  args: {scenario: 'available'},
179
171
  play: async ({canvasElement}) => {
180
172
  const canvas = within(canvasElement);
181
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
173
+ await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
182
174
  await userEvent.type(
183
175
  await canvas.findByRole('searchbox', {name: 'Search providers'}),
184
176
  'not-a-provider',
@@ -191,7 +183,7 @@ export const ConfigureModalOpen: Story = {
191
183
  args: {scenario: 'available'},
192
184
  play: async ({canvasElement}) => {
193
185
  const canvas = within(canvasElement);
194
- await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
186
+ await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
195
187
  await userEvent.click(await canvas.findByRole('button', {name: 'Configure Anthropic'}));
196
188
  await screen.findByLabelText('API key');
197
189
  },
@@ -204,7 +196,11 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
204
196
  if (scenario === 'loading') return new Promise<Response>(() => undefined);
205
197
  if (url.pathname.endsWith('/agent/model-provider-catalog')) {
206
198
  if (scenario === 'catalog-error') return Promise.resolve(errorResponse());
207
- return Promise.resolve(jsonResponse({providers: catalogForScenario(scenario)}));
199
+ return Promise.resolve(
200
+ jsonResponse({
201
+ providers: catalogForScenario(scenario),
202
+ }),
203
+ );
208
204
  }
209
205
  if (request?.method === 'PUT' && url.pathname.includes('/agent/model-providers/')) {
210
206
  return Promise.resolve(
@@ -1,12 +1,14 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
2
  import {Toaster} from '@shipfox/react-ui/toast';
3
3
  import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
- import {render, screen, waitFor, within} from '@testing-library/react';
4
+ import {act, render, screen, waitFor, within} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
6
  import type {ReactElement} from 'react';
7
+ import {modelProviderQueryKeys} from '#hooks/api/model-providers.js';
7
8
  import {isModelProviderOnboardingDismissed} from '#state/model-provider-onboarding.js';
8
9
  import {
9
10
  AGENT_TEST_WORKSPACE_ID,
11
+ managedModelProviderEntry,
10
12
  modelProviderCatalogResponse,
11
13
  modelProviderConfig,
12
14
  modelProviderEntry,
@@ -29,12 +31,15 @@ function requestPath(input: RequestInfo | URL): string {
29
31
  function renderOnboarding(element: ReactElement) {
30
32
  const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
31
33
 
32
- return render(
33
- <QueryClientProvider client={queryClient}>
34
- {element}
35
- <Toaster />
36
- </QueryClientProvider>,
37
- );
34
+ return {
35
+ queryClient,
36
+ ...render(
37
+ <QueryClientProvider client={queryClient}>
38
+ {element}
39
+ <Toaster />
40
+ </QueryClientProvider>,
41
+ ),
42
+ };
38
43
  }
39
44
 
40
45
  describe('ModelProviderOnboardingPage', () => {
@@ -42,6 +47,61 @@ describe('ModelProviderOnboardingPage', () => {
42
47
  window.localStorage.clear();
43
48
  });
44
49
 
50
+ test('waits for the catalog policy before rendering harness selection', async () => {
51
+ let resolveCatalog!: (response: Response) => void;
52
+ const catalogResponse = new Promise<Response>((resolve) => {
53
+ resolveCatalog = resolve;
54
+ });
55
+ configureApiClient({
56
+ baseUrl: 'https://api.example.test',
57
+ fetchImpl: vi.fn().mockReturnValue(catalogResponse),
58
+ });
59
+
60
+ renderOnboarding(
61
+ <ModelProviderOnboardingPage
62
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
63
+ onSkip={vi.fn()}
64
+ onConfigured={vi.fn()}
65
+ />,
66
+ );
67
+
68
+ expect(screen.getByRole('status', {name: 'Loading model providers'})).toBeVisible();
69
+ expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
70
+ expect(screen.getByRole('button', {name: 'Skip for now'})).toBeVisible();
71
+
72
+ resolveCatalog(jsonResponse(modelProviderCatalogResponse()));
73
+
74
+ expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
75
+ await waitFor(() =>
76
+ expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toHaveFocus(),
77
+ );
78
+ });
79
+
80
+ test('continues directly when workspace providers are managed by the instance', async () => {
81
+ const onConfigured = vi.fn();
82
+ const fetchImpl = vi
83
+ .fn()
84
+ .mockResolvedValue(
85
+ jsonResponse(modelProviderCatalogResponse([managedModelProviderEntry()], 'disabled')),
86
+ );
87
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
88
+
89
+ renderOnboarding(
90
+ <ModelProviderOnboardingPage
91
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
92
+ onSkip={vi.fn()}
93
+ onConfigured={onConfigured}
94
+ />,
95
+ );
96
+
97
+ await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
98
+ expect(
99
+ screen.queryByRole('heading', {name: 'Managed inference is ready'}),
100
+ ).not.toBeInTheDocument();
101
+ expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
102
+ expect(screen.queryByLabelText('API key')).not.toBeInTheDocument();
103
+ });
104
+
45
105
  test('skips setup, records the dismissed flag, and does not save a provider or harness', async () => {
46
106
  const user = userEvent.setup();
47
107
  const onSkip = vi.fn();
@@ -57,7 +117,7 @@ describe('ModelProviderOnboardingPage', () => {
57
117
  />,
58
118
  );
59
119
 
60
- await user.click(screen.getByRole('button', {name: 'Skip for now'}));
120
+ await user.click(await screen.findByRole('button', {name: 'Skip for now'}));
61
121
 
62
122
  expect(onSkip).toHaveBeenCalledTimes(1);
63
123
  expect(onConfigured).not.toHaveBeenCalled();
@@ -65,7 +125,7 @@ describe('ModelProviderOnboardingPage', () => {
65
125
  expect(fetchImpl.mock.calls.some(([input]) => (input as Request).method === 'PUT')).toBe(false);
66
126
  });
67
127
 
68
- test('places skip before the harness choices', () => {
128
+ test('places skip before the harness choices', async () => {
69
129
  configureApiClient({
70
130
  baseUrl: 'https://api.example.test',
71
131
  fetchImpl: vi.fn().mockResolvedValue(jsonResponse(modelProviderCatalogResponse())),
@@ -79,8 +139,8 @@ describe('ModelProviderOnboardingPage', () => {
79
139
  />,
80
140
  );
81
141
 
82
- const skip = screen.getByRole('button', {name: 'Skip for now'});
83
- const harness = screen.getByRole('button', {name: 'Choose pi'});
142
+ const skip = await screen.findByRole('button', {name: 'Skip for now'});
143
+ const harness = await screen.findByRole('button', {name: 'Choose pi'});
84
144
 
85
145
  expect(skip.compareDocumentPosition(harness)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
86
146
  });
@@ -112,19 +172,19 @@ describe('ModelProviderOnboardingPage', () => {
112
172
  />,
113
173
  );
114
174
 
115
- expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toBeVisible();
116
- expect(screen.getByRole('button', {name: 'Choose pi'})).toBeVisible();
175
+ expect(await screen.findByRole('heading', {name: 'Choose agent harness'})).toBeVisible();
176
+ expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
117
177
  expect(screen.getByText('Works with 30+ model providers')).toBeVisible();
118
- expect(screen.getByRole('button', {name: 'Choose Claude'})).toBeVisible();
178
+ expect(await screen.findByRole('button', {name: 'Choose Claude'})).toBeVisible();
119
179
  expect(screen.getByText('Runs on your Anthropic API key')).toBeVisible();
120
180
 
121
- await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
181
+ await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
122
182
 
123
183
  expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
124
184
  expect(screen.queryByRole('button', {name: 'Configure OpenAI'})).not.toBeInTheDocument();
125
185
 
126
186
  await user.click(screen.getByRole('button', {name: 'Back'}));
127
- await user.click(screen.getByRole('button', {name: 'Choose pi'}));
187
+ await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
128
188
 
129
189
  expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
130
190
  expect(screen.getByRole('button', {name: 'Configure OpenAI'})).toBeVisible();
@@ -146,7 +206,7 @@ describe('ModelProviderOnboardingPage', () => {
146
206
  onConfigured={vi.fn()}
147
207
  />,
148
208
  );
149
- await user.click(screen.getByRole('button', {name: 'Choose pi'}));
209
+ await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
150
210
  const search = await screen.findByRole('searchbox', {name: 'Search providers'});
151
211
 
152
212
  await user.type(search, 'provider 6');
@@ -182,7 +242,7 @@ describe('ModelProviderOnboardingPage', () => {
182
242
  />,
183
243
  );
184
244
 
185
- await user.click(screen.getByRole('button', {name: 'Skip for now'}));
245
+ await user.click(await screen.findByRole('button', {name: 'Skip for now'}));
186
246
 
187
247
  expect(onSkip).toHaveBeenCalledTimes(1);
188
248
  } finally {
@@ -224,7 +284,7 @@ describe('ModelProviderOnboardingPage', () => {
224
284
  />,
225
285
  );
226
286
 
227
- await user.click(screen.getByRole('button', {name: 'Choose pi'}));
287
+ await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
228
288
  await user.click(await screen.findByRole('button', {name: 'Configure OpenAI'}));
229
289
  await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
230
290
  await user.click(screen.getByRole('button', {name: 'Test & save'}));
@@ -274,7 +334,7 @@ describe('ModelProviderOnboardingPage', () => {
274
334
  />,
275
335
  );
276
336
 
277
- await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
337
+ await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
278
338
  await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
279
339
  await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
280
340
  await user.click(screen.getByRole('button', {name: 'Test & save'}));
@@ -328,7 +388,7 @@ describe('ModelProviderOnboardingPage', () => {
328
388
  />,
329
389
  );
330
390
 
331
- await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
391
+ await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
332
392
  await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
333
393
  await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
334
394
  await user.click(screen.getByRole('button', {name: 'Test & save'}));
@@ -343,7 +403,7 @@ describe('ModelProviderOnboardingPage', () => {
343
403
  await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
344
404
  });
345
405
 
346
- test('keeps skip available when the catalog fails to load after selecting a harness', async () => {
406
+ test('renders the catalog load error and keeps skip available', async () => {
347
407
  const user = userEvent.setup();
348
408
  const onSkip = vi.fn();
349
409
  configureApiClient({
@@ -359,10 +419,59 @@ describe('ModelProviderOnboardingPage', () => {
359
419
  />,
360
420
  );
361
421
 
362
- await user.click(screen.getByRole('button', {name: 'Choose pi'}));
363
422
  expect(await screen.findByText("Couldn't load model provider catalog")).toBeInTheDocument();
423
+ expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
364
424
  await user.click(screen.getByRole('button', {name: 'Skip for now'}));
365
-
366
425
  expect(onSkip).toHaveBeenCalledTimes(1);
367
426
  });
427
+
428
+ test('recovers from a catalog load error after retry', async () => {
429
+ const user = userEvent.setup();
430
+ const fetchImpl = vi
431
+ .fn()
432
+ .mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
433
+ .mockResolvedValueOnce(jsonResponse(modelProviderCatalogResponse()));
434
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
435
+
436
+ renderOnboarding(
437
+ <ModelProviderOnboardingPage
438
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
439
+ onSkip={vi.fn()}
440
+ onConfigured={vi.fn()}
441
+ />,
442
+ );
443
+
444
+ expect(await screen.findByText("Couldn't load model provider catalog")).toBeInTheDocument();
445
+ await user.click(screen.getByRole('button', {name: 'Retry loading model provider catalog'}));
446
+
447
+ expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
448
+ await waitFor(() =>
449
+ expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toHaveFocus(),
450
+ );
451
+ });
452
+
453
+ test('keeps the loaded harness choices when a catalog refetch fails', async () => {
454
+ const fetchImpl = vi
455
+ .fn()
456
+ .mockResolvedValueOnce(jsonResponse(modelProviderCatalogResponse()))
457
+ .mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}));
458
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
459
+
460
+ const {queryClient} = renderOnboarding(
461
+ <ModelProviderOnboardingPage
462
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
463
+ onSkip={vi.fn()}
464
+ onConfigured={vi.fn()}
465
+ />,
466
+ );
467
+
468
+ expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
469
+
470
+ await act(async () => {
471
+ await queryClient.refetchQueries({queryKey: modelProviderQueryKeys.catalog()});
472
+ });
473
+
474
+ expect(screen.getByRole('button', {name: 'Choose pi'})).toBeVisible();
475
+ expect(screen.queryByText("Couldn't load model provider catalog")).not.toBeInTheDocument();
476
+ });
368
477
  });
@@ -6,7 +6,7 @@ import {Modal, ModalContent, ModalHeader, ModalTitle} from '@shipfox/react-ui/mo
6
6
  import {Panel, PanelBody, PanelCell, PanelCellAction, PanelGrid} from '@shipfox/react-ui/panel';
7
7
  import {toast} from '@shipfox/react-ui/toast';
8
8
  import {Header, Text} from '@shipfox/react-ui/typography';
9
- import {useEffect, useMemo, useReducer} from 'react';
9
+ import {useEffect, useMemo, useReducer, useRef} from 'react';
10
10
  import {AvailableProvidersGrid} from '#components/available-providers-grid.js';
11
11
  import {modelProviderConfigErrorToFormError} from '#components/form-errors.js';
12
12
  import {ModelProviderGridSkeleton} from '#components/model-provider-grid-skeleton.js';
@@ -14,7 +14,7 @@ import {ModelProviderTestAndSaveForm} from '#components/test-and-save-form.js';
14
14
  import {DEFAULT_HARNESS, harnessSupportsProvider, listHarnesses} from '#core/harness-policy.js';
15
15
  import type {HarnessDescriptor, HarnessId, SupportedProvider} from '#core/models.js';
16
16
  import {initialOnboardingState, onboardingReducer} from '#core/onboarding-reducer.js';
17
- import {isSupportedProvider} from '#core/provider-policy.js';
17
+ import {isManagedOnlyCatalog, isSupportedProvider} from '#core/provider-policy.js';
18
18
  import {
19
19
  useModelProviderCatalogQuery,
20
20
  useSetDefaultHarnessMutation,
@@ -34,6 +34,11 @@ export function ModelProviderOnboardingPage({
34
34
  const setDefaultHarness = useSetDefaultHarnessMutation();
35
35
  const [onboarding, dispatch] = useReducer(onboardingReducer, initialOnboardingState);
36
36
  const supportedProviders = catalogQuery.data?.providers.filter(isSupportedProvider) ?? [];
37
+ const catalogLoaded = catalogQuery.data !== undefined;
38
+ const catalogLoading = catalogQuery.isPending;
39
+ const catalogLoadError = catalogQuery.isError && catalogQuery.data === undefined;
40
+ const managedOnly = isManagedOnlyCatalog(catalogQuery.data);
41
+ const managedOnlyForwarded = useRef(false);
37
42
  const filteredProviders = useMemo(() => {
38
43
  if (onboarding.step === 'choose-harness') return [];
39
44
  return supportedProviders.filter((provider) =>
@@ -42,6 +47,13 @@ export function ModelProviderOnboardingPage({
42
47
  }, [onboarding, supportedProviders]);
43
48
 
44
49
  useEffect(() => {
50
+ if (!managedOnly || managedOnlyForwarded.current) return;
51
+ managedOnlyForwarded.current = true;
52
+ onConfigured();
53
+ }, [managedOnly, onConfigured]);
54
+
55
+ useEffect(() => {
56
+ if (managedOnly || !catalogLoaded) return;
45
57
  document
46
58
  .getElementById(
47
59
  onboarding.step === 'choose-harness'
@@ -49,7 +61,9 @@ export function ModelProviderOnboardingPage({
49
61
  : 'model-provider-provider-step',
50
62
  )
51
63
  ?.focus();
52
- }, [onboarding.step]);
64
+ }, [catalogLoaded, managedOnly, onboarding.step]);
65
+
66
+ if (managedOnly) return null;
53
67
 
54
68
  function handleSkip() {
55
69
  try {
@@ -120,27 +134,36 @@ export function ModelProviderOnboardingPage({
120
134
  </header>
121
135
 
122
136
  <section aria-labelledby={headingId} className="flex flex-col gap-group">
123
- {onboarding.step === 'choose-harness' ? (
124
- <HarnessPicker
125
- onSelect={(harnessId) => dispatch({type: 'harness-selected', harnessId})}
126
- />
127
- ) : (
128
- <>
129
- <div>
130
- <Button type="button" variant="transparent" onClick={() => dispatch({type: 'back'})}>
131
- Back
132
- </Button>
133
- </div>
134
- <ModelProviderPicker
135
- key={onboarding.harnessId}
136
- catalogQuery={catalogQuery}
137
- supportedProviders={filteredProviders}
138
- onSelect={(entry) => {
139
- dispatch({type: 'provider-selected', provider: entry});
140
- }}
137
+ {catalogLoading ? <ModelProviderGridSkeleton label="Loading model providers" /> : null}
138
+ {catalogLoadError ? (
139
+ <QueryLoadError query={catalogQuery} subject="model provider catalog" />
140
+ ) : null}
141
+ {!catalogLoading && !catalogLoadError ? (
142
+ onboarding.step === 'choose-harness' ? (
143
+ <HarnessPicker
144
+ onSelect={(harnessId) => dispatch({type: 'harness-selected', harnessId})}
141
145
  />
142
- </>
143
- )}
146
+ ) : (
147
+ <>
148
+ <div>
149
+ <Button
150
+ type="button"
151
+ variant="transparent"
152
+ onClick={() => dispatch({type: 'back'})}
153
+ >
154
+ Back
155
+ </Button>
156
+ </div>
157
+ <ModelProviderPicker
158
+ key={onboarding.harnessId}
159
+ supportedProviders={filteredProviders}
160
+ onSelect={(entry) => {
161
+ dispatch({type: 'provider-selected', provider: entry});
162
+ }}
163
+ />
164
+ </>
165
+ )
166
+ ) : null}
144
167
  </section>
145
168
 
146
169
  <Modal
@@ -242,23 +265,13 @@ function HarnessCard({harness, onChoose}: {harness: HarnessDescriptor; onChoose:
242
265
  }
243
266
 
244
267
  function ModelProviderPicker({
245
- catalogQuery,
246
268
  supportedProviders,
247
269
  onSelect,
248
270
  }: {
249
- catalogQuery: ReturnType<typeof useModelProviderCatalogQuery>;
250
271
  supportedProviders: SupportedProvider[];
251
272
  onSelect: (entry: SupportedProvider) => void;
252
273
  }) {
253
- if (catalogQuery.isPending) {
254
- return <ModelProviderGridSkeleton label="Loading model providers" />;
255
- }
256
-
257
- if (catalogQuery.isError && catalogQuery.data === undefined) {
258
- return <QueryLoadError query={catalogQuery} subject="model provider catalog" />;
259
- }
260
-
261
- if (catalogQuery.data !== undefined && supportedProviders.length === 0) {
274
+ if (supportedProviders.length === 0) {
262
275
  return (
263
276
  <EmptyState
264
277
  icon="componentLine"
@@ -4,6 +4,7 @@ import type {
4
4
  ModelProviderCatalogEntryDto,
5
5
  ModelProviderCatalogResponseDto,
6
6
  ModelProviderConfigDto,
7
+ WorkspaceProvidersPolicy,
7
8
  } from '@shipfox/api-agent-dto';
8
9
 
9
10
  export const AGENT_TEST_WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
@@ -35,6 +36,22 @@ export function modelProviderEntry(
35
36
  };
36
37
  }
37
38
 
39
+ export function managedModelProviderEntry(
40
+ overrides: Partial<ModelProviderCatalogEntryDto> = {},
41
+ ): ModelProviderCatalogEntryDto {
42
+ return modelProviderEntry({
43
+ id: 'shipfox',
44
+ label: 'Shipfox Managed',
45
+ default_model: 'claude-opus-4-8',
46
+ credential_fields: [],
47
+ models: [
48
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8', api: 'anthropic-messages'},
49
+ {id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'},
50
+ ],
51
+ ...overrides,
52
+ });
53
+ }
54
+
38
55
  export function unsupportedModelProviderEntry(
39
56
  overrides: Partial<ModelProviderCatalogEntryDto> = {},
40
57
  ): ModelProviderCatalogEntryDto {
@@ -95,8 +112,12 @@ export function customModelProviderConfig(
95
112
 
96
113
  export function modelProviderCatalogResponse(
97
114
  modelProviders: ModelProviderCatalogEntryDto[] = [modelProviderEntry()],
115
+ workspaceProviders?: WorkspaceProvidersPolicy,
98
116
  ): ModelProviderCatalogResponseDto {
99
- return {providers: modelProviders};
117
+ return {
118
+ providers: modelProviders,
119
+ ...(workspaceProviders === undefined ? {} : {workspace_providers: workspaceProviders}),
120
+ };
100
121
  }
101
122
 
102
123
  export function modelProviderConfigsResponse(